From dc0ed15360631f660f712b950d9305c5d7cd1a1d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 04:27:52 -0500 Subject: [PATCH 01/18] Tests(chore): Share the PTY driver and add a native terminal client why: Control clients now need tmux 3.7, but several integration cases only needed some client attached to a session, and the workspace CLI process test kept a private copy of the PTY helper. Both need the same standard-library pseudo-terminal driver. what: - Move the PTY helper into gradle/test-fixtures and serve it as a test resource of integration-tests and libtmux-workspace-cli, so it never enters a published artifact. - Add NativeClient, an ordinary terminal client that attaches to a session through that driver, owns its process tree and detaches on close. - ProcessTest reads the shared helper. Python 3 is already required by the PTY tests; CONTRIBUTING is updated with the control commit's documentation. --- .../io/github/libtmux/fixtures/pty.py | 21 +++ integration-tests/build.gradle.kts | 3 + .../io/github/libtmux/it/NativeClient.java | 160 ++++++++++++++++++ libtmux-workspace-cli/build.gradle.kts | 3 + .../libtmux/workspace/cli/ProcessTest.java | 35 ++-- 5 files changed, 199 insertions(+), 23 deletions(-) create mode 100644 gradle/test-fixtures/io/github/libtmux/fixtures/pty.py create mode 100644 integration-tests/src/test/java/io/github/libtmux/it/NativeClient.java diff --git a/gradle/test-fixtures/io/github/libtmux/fixtures/pty.py b/gradle/test-fixtures/io/github/libtmux/fixtures/pty.py new file mode 100644 index 000000000..20fceda7f --- /dev/null +++ b/gradle/test-fixtures/io/github/libtmux/fixtures/pty.py @@ -0,0 +1,21 @@ +import errno, os, select, subprocess, time +def drain_pty(fd): + chunks = [] + while select.select([fd], [], [], 0)[0]: + try: chunk = os.read(fd, 65536) + except OSError as error: + if error.errno == errno.EIO: break + raise + if not chunk: break + chunks.append(chunk) + return b''.join(chunks) +def wait_pty(child, master, timeout): + until = time.monotonic() + timeout + while child.poll() is None: + drain_pty(master) + remaining = until - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(child.args, timeout) + select.select([master], [], [], min(remaining, .025)) + drain_pty(master) + return child.returncode diff --git a/integration-tests/build.gradle.kts b/integration-tests/build.gradle.kts index f1c589897..a0d8174a7 100644 --- a/integration-tests/build.gradle.kts +++ b/integration-tests/build.gradle.kts @@ -73,3 +73,6 @@ val localeTest = } tasks.check { dependsOn(localeTest) } + +// Shared native terminal driver; test resources never enter a published artifact. +sourceSets.test { resources.srcDir(rootProject.file("gradle/test-fixtures")) } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/NativeClient.java b/integration-tests/src/test/java/io/github/libtmux/it/NativeClient.java new file mode 100644 index 000000000..bfc577032 --- /dev/null +++ b/integration-tests/src/test/java/io/github/libtmux/it/NativeClient.java @@ -0,0 +1,160 @@ +package io.github.libtmux.it; + +import io.github.libtmux.Client; +import io.github.libtmux.Server; +import io.github.libtmux.Session; +import io.github.libtmux.internal.CommandStrings; +import io.github.libtmux.internal.ProcessTree; +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.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** Owns an ordinary terminal client for a local test server. */ +final class NativeClient implements AutoCloseable { + private static final String DRIVER = helper() + """ + import fcntl, pty, signal, sys, termios + master, slave = pty.openpty() + os.set_blocking(master, False) + def terminal(): + os.setsid() + fcntl.ioctl(slave, termios.TIOCSCTTY, 0) + signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) + child = None + trace = bytearray() + try: + child = subprocess.Popen(sys.argv[1:], stdin=slave, stdout=slave, + stderr=slave, env=dict(os.environ, TERM='xterm'), preexec_fn=terminal) + os.close(slave) + slave = None + while child.poll() is None: + chunk = drain_pty(master) + trace.extend(chunk[:max(0, 65536-len(trace))]) + if 0 in select.select([0, master], [], [], .025)[0]: + data = os.read(0, 65536) + if not data: break + os.write(master, data) + finally: + if child is not None: + if child.poll() is None: child.terminate() + try: wait_pty(child, master, 2) + except subprocess.TimeoutExpired: + child.kill() + wait_pty(child, master, 2) + if child.returncode not in (0, -signal.SIGTERM): + sys.stderr.buffer.write(trace) + if slave is not None: os.close(slave) + os.close(master) + """; + private final Server server; + private final Process process; + private final ProcessTree tree; + private final Path log; + private String name = ""; + + private NativeClient(Server server, Process process, Path log) { + this.server = server; + this.process = process; + this.tree = new ProcessTree(process); + this.log = log; + } + + static NativeClient attach(Session session) throws Exception { + Server server = session.server(); + Set before = server.clients().stream().map(Client::name).collect(Collectors.toSet()); + Path log = Files.createTempFile(Path.of(System.getProperty("java.io.tmpdir")), "ordinary-client-", ".log"); + List command = new ArrayList<>(List.of("python3", "-u", "-c", DRIVER)); + command.addAll(server.config().endpointCommand()); + command.addAll(List.of("attach-session", "-t", session.id().value())); + ProcessBuilder builder = + new ProcessBuilder(command).redirectErrorStream(true).redirectOutput(log.toFile()); + builder.environment().remove("TMUX"); + builder.environment().remove("TMUX_PANE"); + Process process; + try { + process = builder.start(); + } catch (IOException failure) { + try { + Files.deleteIfExists(log); + } catch (IOException cleanup) { + failure.addSuppressed(cleanup); + } + throw failure; + } + NativeClient client = new NativeClient(server, process, log); + try { + if (!Await.until(() -> !client.process.isAlive() + || server.clients().stream().anyMatch(seen -> !before.contains(seen.name()))) + || !client.process.isAlive()) { + throw new IOException("ordinary client did not attach: " + Files.readString(log)); + } + client.name = server.clients().stream() + .map(Client::name) + .filter(seen -> !before.contains(seen)) + .findFirst() + .orElseThrow(() -> new IOException("ordinary client disappeared after attaching")); + return client; + } catch (Exception | Error failure) { + try { + client.close(); + } catch (IOException cleanup) { + failure.addSuppressed(cleanup); + } + throw failure; + } + } + + boolean isAlive() { + return process.isAlive(); + } + + void run(String... argv) throws Exception { + String marker = "@ordinary-client-" + UUID.randomUUID(); + String command = CommandStrings.group(List.of(List.of(argv), List.of("set-option", "-g", marker, "done"))); + try { + var prompt = server.cmd("command-prompt", "-b", "-t", name); + if (!prompt.succeeded()) throw new IOException("ordinary client prompt failed: " + prompt.stderr()); + process.getOutputStream().write((command + "\r").getBytes(StandardCharsets.UTF_8)); + process.getOutputStream().flush(); + if (!Await.until(() -> server.globalOptions().get(marker).isPresent())) { + throw new IOException("ordinary client did not complete its command: " + Files.readString(log)); + } + } finally { + server.globalOptions().unset(marker); + } + } + + @Override + public void close() throws IOException { + tree.captureDescendants(); + boolean reclaimed; + try { + process.getOutputStream().close(); + if (!process.waitFor(5, TimeUnit.SECONDS)) throw new IOException("ordinary client did not stop"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("interrupted while closing ordinary client", interrupted); + } finally { + reclaimed = tree.terminate(); + } + if (!reclaimed) throw new IOException("ordinary client process tree survived"); + if (process.exitValue() != 0) throw new IOException("ordinary client failed: " + Files.readString(log)); + Files.deleteIfExists(log); + } + + private static String helper() { + try (var source = NativeClient.class.getResourceAsStream("/io/github/libtmux/fixtures/pty.py")) { + if (source == null) throw new IllegalStateException("missing PTY test helper"); + return new String(source.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException failure) { + throw new UncheckedIOException(failure); + } + } +} diff --git a/libtmux-workspace-cli/build.gradle.kts b/libtmux-workspace-cli/build.gradle.kts index 3abd19e55..6ef94bb68 100644 --- a/libtmux-workspace-cli/build.gradle.kts +++ b/libtmux-workspace-cli/build.gradle.kts @@ -89,3 +89,6 @@ tasks.register("runDevelopment") { ) jvmArgs("-XX:TieredStopAtLevel=1") } + +// Shared native terminal driver; test resources never enter a published artifact. +sourceSets.test { resources.srcDir(rootProject.file("gradle/test-fixtures")) } diff --git a/libtmux-workspace-cli/src/test/java/io/github/libtmux/workspace/cli/ProcessTest.java b/libtmux-workspace-cli/src/test/java/io/github/libtmux/workspace/cli/ProcessTest.java index 59591d0a1..346105edd 100644 --- a/libtmux-workspace-cli/src/test/java/io/github/libtmux/workspace/cli/ProcessTest.java +++ b/libtmux-workspace-cli/src/test/java/io/github/libtmux/workspace/cli/ProcessTest.java @@ -10,7 +10,9 @@ import io.github.libtmux.Server; import io.github.libtmux.ServerEndpoint; import java.io.BufferedReader; +import java.io.IOException; import java.io.InputStreamReader; +import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -26,29 +28,16 @@ /** Drives the installed launcher as a real process, which is why the whole class is tagged. */ @org.junit.jupiter.api.Tag("distribution") final class ProcessTest { - private static final String PTY = """ - import errno, os, select, subprocess, time - def drain_pty(fd): - chunks = [] - while select.select([fd], [], [], 0)[0]: - try: chunk = os.read(fd, 65536) - except OSError as error: - if error.errno == errno.EIO: break - raise - if not chunk: break - chunks.append(chunk) - return b''.join(chunks) - def wait_pty(child, master, timeout): - until = time.monotonic() + timeout - while child.poll() is None: - drain_pty(master) - remaining = until - time.monotonic() - if remaining <= 0: - raise subprocess.TimeoutExpired(child.args, timeout) - select.select([master], [], [], min(remaining, .025)) - drain_pty(master) - return child.returncode - """; + private static final String PTY = ptyHelper(); + + private static String ptyHelper() { + try (var source = ProcessTest.class.getResourceAsStream("/io/github/libtmux/fixtures/pty.py")) { + if (source == null) throw new IllegalStateException("missing PTY test helper"); + return new String(source.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException failure) { + throw new UncheckedIOException(failure); + } + } @TempDir Path directory; From 7630d1b1048f0520d179e1976fdd2f969c5023da Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 04:27:52 -0500 Subject: [PATCH 02/18] Workspace(fix): Require Python optional feature policy v2 why: The workspace CLI hands builders, plugins and shell code to a Python libtmux. The core version check (3.3a) alone does not tell the CLI that interpreter also refuses the optional operations Java refuses, so a backend with only the floor could run an unsafe literal command. what: - The runtime prelude requires the integer TMUX_OPTIONAL_FEATURE_POLICY_VERSION >= 2 next to the 3.3a floor, in the runtime probe and again in the child, before external code runs. - Cases cover a missing, 0, 1, boolean, string and float marker, and a child that passes the probe but fails the recheck; none enters the backend. - CI and the tmux matrix qualify tmuxp 1.74.0 with the Python libtmux source overlay that carries the marker (tmux-python/libtmux PR 780), and assert the marker besides the import path and floor. - The refusal text names TMUX_OPTIONAL_FEATURE_POLICY_VERSION >= 2, says no released libtmux has it yet, and points at tmux-python/libtmux#780. Released tmuxp still pins libtmux 0.61, so the overlay is not a coherent installed package pair. --- .github/workflows/ci.yml | 6 +-- .github/workflows/tmux-matrix.yml | 6 +-- .../libtmux/workspace/cli/Children.java | 7 ++- .../workspace/cli/PythonExtensions.java | 4 ++ .../libtmux/workspace/cli/ExecutionTest.java | 53 ++++++++++++++++++- 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab198d895..46da43658 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,11 +81,11 @@ jobs: "$RUNNER_TEMP/workspace-python/bin/python" -m pip install 'tmuxp==1.74.0' git init --quiet "$RUNNER_TEMP/python-libtmux-core" git -C "$RUNNER_TEMP/python-libtmux-core" fetch --quiet --depth=1 \ - https://github.com/tmux-python/libtmux.git c27dd929fc25c3a95648759d8c0537a07ef60258 + https://github.com/tmux-python/libtmux.git 5df9cb3e89647577b7dd8484f844ffe1053129bb git -C "$RUNNER_TEMP/python-libtmux-core" checkout --quiet --detach \ - c27dd929fc25c3a95648759d8c0537a07ef60258 + 5df9cb3e89647577b7dd8484f844ffe1053129bb PYTHONPATH="$RUNNER_TEMP/python-libtmux-core/src" "$RUNNER_TEMP/workspace-python/bin/python" -c \ - 'import sys, libtmux; from pathlib import Path; from libtmux.common import TMUX_MIN_VERSION; assert TMUX_MIN_VERSION == "3.3a", TMUX_MIN_VERSION; assert Path(libtmux.__file__).resolve() == Path(sys.argv[1]).resolve() / "libtmux" / "__init__.py", libtmux.__file__; print(TMUX_MIN_VERSION, libtmux.__file__)' \ + 'import sys, libtmux; from pathlib import Path; from libtmux.common import TMUX_MIN_VERSION, TMUX_OPTIONAL_FEATURE_POLICY_VERSION as policy; assert type(policy) is int and policy >= 2, policy; assert TMUX_MIN_VERSION == "3.3a", TMUX_MIN_VERSION; assert Path(libtmux.__file__).resolve() == Path(sys.argv[1]).resolve() / "libtmux" / "__init__.py", libtmux.__file__; print(TMUX_MIN_VERSION, policy, libtmux.__file__)' \ "$RUNNER_TEMP/python-libtmux-core/src" echo "PYTHONPATH=$RUNNER_TEMP/python-libtmux-core/src" >> "$GITHUB_ENV" echo "TMUX_WORKSPACE_TEST_PYTHON=$RUNNER_TEMP/workspace-python/bin/python" >> "$GITHUB_ENV" diff --git a/.github/workflows/tmux-matrix.yml b/.github/workflows/tmux-matrix.yml index ea900ce5d..df7f0459a 100644 --- a/.github/workflows/tmux-matrix.yml +++ b/.github/workflows/tmux-matrix.yml @@ -209,11 +209,11 @@ jobs: "$RUNNER_TEMP/workspace-python/bin/python" -m pip install 'tmuxp==1.74.0' git init --quiet "$RUNNER_TEMP/python-libtmux-core" git -C "$RUNNER_TEMP/python-libtmux-core" fetch --quiet --depth=1 \ - https://github.com/tmux-python/libtmux.git c27dd929fc25c3a95648759d8c0537a07ef60258 + https://github.com/tmux-python/libtmux.git 5df9cb3e89647577b7dd8484f844ffe1053129bb git -C "$RUNNER_TEMP/python-libtmux-core" checkout --quiet --detach \ - c27dd929fc25c3a95648759d8c0537a07ef60258 + 5df9cb3e89647577b7dd8484f844ffe1053129bb PYTHONPATH="$RUNNER_TEMP/python-libtmux-core/src" "$RUNNER_TEMP/workspace-python/bin/python" -c \ - 'import sys, libtmux; from pathlib import Path; from libtmux.common import TMUX_MIN_VERSION; assert TMUX_MIN_VERSION == "3.3a", TMUX_MIN_VERSION; assert Path(libtmux.__file__).resolve() == Path(sys.argv[1]).resolve() / "libtmux" / "__init__.py", libtmux.__file__; print(TMUX_MIN_VERSION, libtmux.__file__)' \ + 'import sys, libtmux; from pathlib import Path; from libtmux.common import TMUX_MIN_VERSION, TMUX_OPTIONAL_FEATURE_POLICY_VERSION as policy; assert type(policy) is int and policy >= 2, policy; assert TMUX_MIN_VERSION == "3.3a", TMUX_MIN_VERSION; assert Path(libtmux.__file__).resolve() == Path(sys.argv[1]).resolve() / "libtmux" / "__init__.py", libtmux.__file__; print(TMUX_MIN_VERSION, policy, libtmux.__file__)' \ "$RUNNER_TEMP/python-libtmux-core/src" echo "PYTHONPATH=$RUNNER_TEMP/python-libtmux-core/src" >> "$GITHUB_ENV" echo "TMUX_WORKSPACE_TEST_PYTHON=$RUNNER_TEMP/workspace-python/bin/python" >> "$GITHUB_ENV" diff --git a/libtmux-workspace-cli/src/main/java/io/github/libtmux/workspace/cli/Children.java b/libtmux-workspace-cli/src/main/java/io/github/libtmux/workspace/cli/Children.java index 3a13aab54..672101e0c 100644 --- a/libtmux-workspace-cli/src/main/java/io/github/libtmux/workspace/cli/Children.java +++ b/libtmux-workspace-cli/src/main/java/io/github/libtmux/workspace/cli/Children.java @@ -305,9 +305,12 @@ static String python(Main.Context context, Reporter report) throws InterruptedEx throw new Main.Failure( Machine.Code.SCRIPT_FAILED, 1, - "tmuxp 1.74.0 is required with Python libtmux's core version check for tmux 3.3a or newer: " + "tmuxp 1.74.0 is required with Python libtmux's core version check and optional feature policy v2: " + refusal(version) - + "; set TMUX_WORKSPACE_PYTHON to an interpreter with tmuxp 1.74.0 and compatible Python libtmux installed"); + + "; set TMUX_WORKSPACE_PYTHON to an interpreter with tmuxp 1.74.0 and a Python libtmux whose" + + " TMUX_OPTIONAL_FEATURE_POLICY_VERSION is 2 or more. No released libtmux has that yet; it" + + " comes with tmux-python/libtmux#780, so install libtmux from that source, or do without" + + " shell and Python extensions"); return executable; } catch (IOException absent) { throw unusableRuntime(absent.getMessage(), absent); diff --git a/libtmux-workspace-cli/src/main/java/io/github/libtmux/workspace/cli/PythonExtensions.java b/libtmux-workspace-cli/src/main/java/io/github/libtmux/workspace/cli/PythonExtensions.java index dbc9338ef..71aab4058 100644 --- a/libtmux-workspace-cli/src/main/java/io/github/libtmux/workspace/cli/PythonExtensions.java +++ b/libtmux-workspace-cli/src/main/java/io/github/libtmux/workspace/cli/PythonExtensions.java @@ -24,10 +24,14 @@ private PythonExtensions() {} static final String CORE_PRELUDE = """ import re as _libtmux_re from libtmux.common import TMUX_MIN_VERSION as _libtmux_floor + import libtmux.common as _libtmux_common _libtmux_floor = str(_libtmux_floor) _libtmux_release = _libtmux_re.fullmatch(r'(\\d+)\\.(\\d+)([a-z]?)', _libtmux_floor) if not _libtmux_release or (int(_libtmux_release[1]), int(_libtmux_release[2]), _libtmux_release[3]) < (%2$d, %3$d, '%4$s'): raise RuntimeError('Python libtmux with a tmux %1$s minimum is required; install a compatible Python libtmux in TMUX_WORKSPACE_PYTHON (loaded floor: ' + _libtmux_floor + ')') + _libtmux_optional = getattr(_libtmux_common, 'TMUX_OPTIONAL_FEATURE_POLICY_VERSION', None) + if type(_libtmux_optional) is not int or _libtmux_optional < 2: + raise RuntimeError('Python libtmux optional feature policy v2 is required (TMUX_OPTIONAL_FEATURE_POLICY_VERSION >= 2); no released libtmux has it yet, it comes with tmux-python/libtmux#780. Install libtmux from that source in TMUX_WORKSPACE_PYTHON, or do without shell and Python extensions') """.formatted( TmuxVersion.MINIMUM, TmuxVersion.MINIMUM.major(), TmuxVersion.MINIMUM.minor(), TmuxVersion.MINIMUM.patch()); diff --git a/libtmux-workspace-cli/src/test/java/io/github/libtmux/workspace/cli/ExecutionTest.java b/libtmux-workspace-cli/src/test/java/io/github/libtmux/workspace/cli/ExecutionTest.java index 3e1568a00..7da460c8b 100644 --- a/libtmux-workspace-cli/src/test/java/io/github/libtmux/workspace/cli/ExecutionTest.java +++ b/libtmux-workspace-cli/src/test/java/io/github/libtmux/workspace/cli/ExecutionTest.java @@ -21,6 +21,7 @@ import java.nio.file.Path; import java.time.Duration; import java.util.HashMap; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; @@ -162,12 +163,62 @@ void aQualifiedPythonBackendCanEnterTheShell() throws Exception { assertTrue(Files.exists(marker), "the qualified child was not entered"); } + @ParameterizedTest + @ValueSource(strings = {"missing", "0", "1", "False", "True", "'2'", "2.0"}) + void pythonOptionalPolicyIsRequiredBeforeExternalCode(String policy) throws Exception { + Path marker = directory.resolve("optional-policy-entered"); + Path backend = pythonBackend("3.3a", marker, policy.equals("missing") ? "" : policy); + assertPythonPolicyRefused(backend, "python3", marker); + } + + @Test + void pythonChildRechecksOptionalPolicyAfterTheRuntimeProbe() throws Exception { + Path marker = directory.resolve("optional-child-entered"); + Path backend = pythonBackend("3.3a", marker, "1"); + Path python = directory.resolve("python-probe-only"); + Files.writeString(python, """ + #!/bin/sh + case "$*" in *importlib.metadata*) printf '1.74.0\\n'; exit;; esac + exec python3 "$@" + """); + assertTrue(python.toFile().setExecutable(true)); + assertPythonPolicyRefused(backend, python.toString(), marker); + } + + private void assertPythonPolicyRefused(Path backend, String python, Path marker) throws Exception { + Path source = directory.resolve("optional-policy.yaml"); + Files.writeString( + source, + "session_name: external\nplugins: [forbidden.Plugin]\nwindows:\n- window_name: main\n panes: [null]\n"); + for (String entry : List.of("load", "shell")) { + Result result = invoke( + java.util.Map.of("PYTHONPATH", backend.toString(), "TMUX_WORKSPACE_PYTHON", python), + entry, + "--json", + "-S", + directory.resolve("s").toString(), + entry.equals("load") ? source.toString() : "-c", + entry.equals("load") ? "-d" : "print('must-not-run')"); + assertFalse(Files.exists(marker), "unqualified backend code ran: " + result.err()); + assertTrue(result.code() != 0, result.out()); + assertTrue( + (result.out() + result.err()).contains("optional feature policy v2"), result.out() + result.err()); + } + } + private Path pythonBackend(String floor, Path marker) throws Exception { + return pythonBackend(floor, marker, "2"); + } + + private Path pythonBackend(String floor, Path marker, String policy) throws Exception { Path backend = directory.resolve("python-" + floor); Files.createDirectories(backend.resolve("libtmux")); Files.writeString( backend.resolve("libtmux/__init__.py"), "class Server:\n def __init__(self, **kwargs): pass\n"); - Files.writeString(backend.resolve("libtmux/common.py"), "TMUX_MIN_VERSION = '" + floor + "'\n"); + Files.writeString( + backend.resolve("libtmux/common.py"), + "TMUX_MIN_VERSION = '" + floor + "'\n" + + (policy.isEmpty() ? "" : "TMUX_OPTIONAL_FEATURE_POLICY_VERSION = " + policy + "\n")); Files.createDirectories(backend.resolve("tmuxp/workspace")); Files.writeString(backend.resolve("tmuxp/__init__.py"), ""); String entered = "from pathlib import Path\nPath(" + Documents.JSON.writeValueAsString(marker.toString()) From 6cea7a375ecf9046797d23275185fb1315924c78 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 04:28:08 -0500 Subject: [PATCH 03/18] Features(fix): Refuse unsafe optional operations before their effects why: At the 3.3a floor a few operations still misbehave on particular releases: named buffer delete and rename act on an unrelated buffer before 3.4, `split-window -p` is unreliable on 3.4, an invalid layout string kills a 3.3a daemon, an unnamed or raw `break-pane` ends or renames on exact 3.7, and a single-pane break prints no id before 3.6. Each must be decided from the connected daemon, for typed calls and for literal commands alike, so it can no longer fail after an earlier command in a batch had its effect. what: - TmuxFeatures inspects the literal commands a request carries (full verbs, aliases and unambiguous prefixes, option clustering, `--`) against the checked daemon version and refuses with UnsupportedFeatureException or IllegalArgumentException before the fenced batch is sent. - Versions.execute applies it after the version check; Server, IncarnationFence and handle batches pass the commands they represent. - Buffers.delete, Pane.paste and its callback form stop checking the version themselves: the dispatch-level guard covers them, and the paste temporary buffer is refused before it is created. - Pane.breakOut() is refused on exact 3.7; breakOut(name) keeps the post-break rename. A break is read back with display-message on the moved pane, so releases that print nothing for a single-pane break still return the new window. - Direct `paste-buffer -d -b`, user-authored if-shell bodies and alias contents keep native behaviour; no second script parser is added. - Server.cmd, batch, chain and ControlClient.send document that nested tmux command strings are not checked. Verified on real tmux with OptionalFeatureIntegrationTest. --- .../it/OptionalFeatureIntegrationTest.java | 183 ++++++++++++++++++ .../it/PaneOperationsIntegrationTest.java | 23 ++- .../main/java/io/github/libtmux/Buffers.java | 15 -- .../io/github/libtmux/IncarnationFence.java | 21 +- .../src/main/java/io/github/libtmux/Pane.java | 71 ++++--- .../main/java/io/github/libtmux/Server.java | 62 +++++- .../github/libtmux/control/ControlClient.java | 17 +- .../github/libtmux/internal/TmuxFeatures.java | 131 +++++++++++++ .../libtmux/internal/support/Versions.java | 32 ++- 9 files changed, 479 insertions(+), 76 deletions(-) create mode 100644 integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java create mode 100644 libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java diff --git a/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java new file mode 100644 index 000000000..70b247c19 --- /dev/null +++ b/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java @@ -0,0 +1,183 @@ +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; + +import io.github.libtmux.Pane; +import io.github.libtmux.Server; +import io.github.libtmux.TmuxVersion; +import io.github.libtmux.Window; +import io.github.libtmux.exception.UnsupportedFeatureException; +import io.github.libtmux.junit5.TmuxExtension; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** Selective refusals preserve ordinary operations at the core floor. */ +@ExtendWith(TmuxExtension.class) +final class OptionalFeatureIntegrationTest { + private static final TmuxVersion BUFFER = new TmuxVersion(3, 4, ""); + + @Test + void malformedLiteralLayoutsCannotApplyAnEarlierBatchCommand(Server server) { + Pane pane = server.panes().getFirst(); + if (!server.version().atLeast(BUFFER)) { + for (List command : List.of( + List.of("select-layout", "not-a-layout"), + List.of("selectl", "-t" + pane.id().value(), "not-a-layout"), + List.of("select-l", "--", "-n"), + List.of("select-layout", "-ot", pane.id().value(), "tile"), + List.of("select-layout", "0000,80x24,0,0,0"))) { + assertThrows(IllegalArgumentException.class, () -> server.cmd(command), command.toString()); + } + assertThrows( + IllegalArgumentException.class, + () -> server.batch() + .add("set-option", "-g", "@layout-effect", "changed") + .add("select-layout", "not-a-layout") + .run()); + assertThrows( + IllegalArgumentException.class, + () -> pane.batch() + .add("set-option", "-g", "@layout-effect", "changed") + .add("select-layout", "-o", "tiled") + .run()); + assertEquals( + List.of(), + server.cmd("show-options", "-gqv", "@layout-effect").stdout()); + } else { + assertFalse(server.cmd("select-layout", "not-a-layout").succeeded()); + } + assertTrue(server.isAlive()); + } + + @Test + void literalLayoutsKeepPresetsSerializedTreesAndIgnoredOperands(Server server) { + String target = server.panes().getFirst().id().value(); + assertTrue(server.cmd("select-l", "-t", target, "tile").succeeded()); + assertTrue(server.cmd("selectl", "-t" + target, "even-h").succeeded()); + String serialized = + server.cmd("display-message", "-p", "#{window_layout}").stdout().getFirst(); + assertTrue(server.cmd("select-layout", "-ot" + target, "--", serialized).succeeded()); + for (String flag : List.of("-n", "-p", "-E")) { + assertTrue(server.cmd("select-layout", flag, "not-a-layout").succeeded(), flag); + } + assertTrue(server.cmd("select-layout", "-o").succeeded()); + assertTrue(server.cmd("select-layout").succeeded()); + assertTrue(server.isAlive()); + } + + @Test + void namedBufferRefusalPrecedesEveryRawAndCapturedBatchEffect(Server server) { + server.buffers().set("user", "keep"); + Pane pane = server.panes().getFirst(); + if (!server.version().atLeast(BUFFER)) { + assertThrows(UnsupportedFeatureException.class, () -> server.cmd("del", "-bmissing")); + assertThrows(UnsupportedFeatureException.class, () -> server.cmd("setb", "-bmissing", "-nrenamed")); + assertThrows( + UnsupportedFeatureException.class, + () -> server.batch() + .add("set-option", "-g", "@forbidden", "changed") + .add("delete-buffer", "-b", "missing") + .run()); + assertThrows( + UnsupportedFeatureException.class, + () -> pane.batch() + .add("set-option", "-g", "@forbidden", "changed") + .add("set-buffer", "-b", "missing", "-n", "renamed") + .run()); + assertEquals( + List.of(), server.cmd("show-options", "-gqv", "@forbidden").stdout()); + assertEquals("keep", server.buffers().show("user")); + } else { + pane.batch().add("set-buffer", "-b", "user", "-n", "renamed").run(); + assertEquals("keep", server.buffers().show("renamed")); + assertFalse(server.cmd("delete-buffer", "-b", "missing").succeeded()); + assertEquals("keep", server.buffers().show("renamed")); + } + } + + @Test + void optionAliasesAndFlagValuesRetainTheirNativeMeaning(Server server) { + assertTrue(server.cmd("set", "-g", "@plain", "kept").succeeded()); + assertTrue(server.cmd("setw", "-g", "@window", "kept").succeeded()); + assertTrue(server.cmd("setb", "-b", "-n", "literal").succeeded()); + assertEquals("literal", server.buffers().show("-n")); + } + + @Test + void directNamedPasteWithDeleteRemainsAvailableAtTheCoreFloor(Server server) throws Exception { + Pane pane = server.panes().getFirst(); + pane.respawn("cat"); + assertTrue(Await.until(() -> pane.expand("#{pane_current_command}").equals("cat"))); + server.buffers().set("exact", "direct-retained"); + assertTrue( + server.cmd("paste-buffer", "-d", "-b", "exact", "-t", pane.id().value()) + .succeeded()); + assertTrue(Await.until(() -> String.join("\n", pane.capture()).contains("direct-retained"))); + assertTrue(server.buffers().list().isEmpty()); + } + + @Test + void managedPasteRequiresCleanupBeforeStagingOrCallingUserCode(Server server) throws Exception { + Pane pane = server.panes().getFirst(); + pane.respawn("cat"); + assertTrue(Await.until(() -> pane.expand("#{pane_current_command}").equals("cat"))); + server.buffers().set("user", "keep"); + AtomicBoolean callback = new AtomicBoolean(); + if (!server.version().atLeast(BUFFER)) { + assertThrows(UnsupportedFeatureException.class, () -> pane.paste("managed-forbidden")); + assertThrows( + UnsupportedFeatureException.class, () -> pane.paste("managed-forbidden", () -> callback.set(true))); + assertFalse(callback.get()); + assertEquals("keep", server.buffers().show("user")); + assertEquals(1, server.buffers().list().size()); + assertFalse(String.join("\n", pane.capture()).contains("managed-forbidden")); + } else { + pane.paste("managed-retained", () -> callback.set(true)); + assertTrue(callback.get()); + assertTrue(Await.until(() -> String.join("\n", pane.capture()).contains("managed-retained"))); + assertEquals(1, server.buffers().list().size()); + } + } + + @Test + void anUnnamedSinglePaneBreakPreservesTheExistingWindowName(Server server) { + Pane pane = server.panes().getFirst(); + assertEquals("preserved", pane.window().rename("preserved").name()); + if (server.version().equals(new TmuxVersion(3, 7, ""))) { + assertThrows(UnsupportedFeatureException.class, pane::breakOut); + assertEquals("preserved", server.windows().getFirst().name()); + } else assertEquals("preserved", pane.breakOut().name()); + assertTrue(server.isAlive()); + } + + @Test + void aNamedBreakRetainsItsNameOnSingleAndMultiplePaneWindows(Server server) { + Pane pane = server.panes().getFirst(); + Window single = pane.breakOut("single"); + assertEquals("single", single.name()); + Pane sibling = single.panes().getFirst().split(); + assertEquals("multiple", sibling.breakOut("multiple").name()); + assertEquals(2, server.windows().size()); + assertTrue(server.isAlive()); + } + + @Test + void percentageSplitsUseTheWorkingTypedFormOnExact34(Server server) { + Pane pane = server.panes().getFirst(); + if (server.version().equals(BUFFER)) { + assertThrows( + UnsupportedFeatureException.class, + () -> server.cmd("sp", "-p", "30", "-t", pane.id().value())); + assertEquals(1, server.panes().size()); + } else + assertTrue( + server.cmd("split-window", "-d", "-p", "30", "-t", pane.id().value()) + .succeeded()); + assertFalse(pane.split(spec -> spec.percent(30)).id().equals(pane.id())); + } +} diff --git a/integration-tests/src/test/java/io/github/libtmux/it/PaneOperationsIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/PaneOperationsIntegrationTest.java index 4ecde1686..21151685c 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/PaneOperationsIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/PaneOperationsIntegrationTest.java @@ -2,12 +2,15 @@ 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.Pane; import io.github.libtmux.Server; import io.github.libtmux.Session; +import io.github.libtmux.TmuxVersion; import io.github.libtmux.Window; +import io.github.libtmux.exception.UnsupportedFeatureException; import io.github.libtmux.junit5.TmuxExtension; import java.util.List; import org.junit.jupiter.api.Test; @@ -24,8 +27,8 @@ final class PaneOperationsIntegrationTest { /** * On tmux 3.7 exactly, letting tmux name the window it breaks a pane into ends the server and - * every session on the socket. The window still has to appear, and the server still has to be - * there afterwards, on every supported release. + * every session on the socket. The unnamed call is refused there before effects; other + * supported releases retain native naming. */ @Test void breakingAPaneOutLeavesTheServerStanding(Server server) { @@ -33,6 +36,13 @@ void breakingAPaneOutLeavesTheServerStanding(Server server) { Window window = session.windows().get(0); Pane split = window.split(); + if (server.version().equals(new TmuxVersion(3, 7, ""))) { + assertThrows(UnsupportedFeatureException.class, split::breakOut); + assertTrue(server.isAlive()); + assertEquals(1, server.windows().size()); + assertEquals(2, server.panes().size()); + return; + } Window broken = split.breakOut(); assertTrue(server.isAlive(), "break-pane must not take the server with it"); @@ -65,9 +75,16 @@ void theUnnamedBreakIsNamedWhatTmuxWouldHaveNamedIt(Server server) throws Except Pane split = server.sessions().get(0).windows().get(0).split(); String command = awaitSettledCommand(split); + if (server.version().equals(new TmuxVersion(3, 7, ""))) { + assertThrows(UnsupportedFeatureException.class, split::breakOut); + assertTrue(server.isAlive()); + assertEquals(1, server.windows().size()); + assertEquals(2, server.panes().size()); + return; + } Window broken = split.breakOut(); - assertEquals(command, broken.name(), "the workaround must not change what the window is called"); + assertEquals(command, broken.name(), "native naming must be retained"); } /** Polls until two consecutive readings agree, so the startup transient is over. */ diff --git a/libtmux/src/main/java/io/github/libtmux/Buffers.java b/libtmux/src/main/java/io/github/libtmux/Buffers.java index 758aad16e..872be3ae0 100644 --- a/libtmux/src/main/java/io/github/libtmux/Buffers.java +++ b/libtmux/src/main/java/io/github/libtmux/Buffers.java @@ -24,17 +24,6 @@ public final class Buffers { private static final RowFormat LISTING = RowFormat.of("buffer_name", "buffer_size"); - /** - * tmux's own history dates the fix to {@code 0f6227f4} ("When deleting or renaming a buffer and - * a buffer name is specified, complain if the buffer doesn't exist instead of silently deleting - * or renaming the most recent buffer", GitHub issue 3205); {@code git tag --contains} places it - * on {@code 3.4} and nothing earlier, so 3.3 and 3.3a both have the bug. Confirmed on the matrix: - * {@code set-buffer -b a x; set-buffer -b b y; delete-buffer -b nope} answers {@code unknown - * buffer: nope} on 3.4 and leaves both buffers, but on 3.3a it exits 0 and takes {@code b}, the - * one {@code delete-buffer} was never told to touch. - */ - static final TmuxVersion EXACT_NAMED_DELETE = new TmuxVersion(3, 4, ""); - private final Server server; Buffers(Server server) { @@ -104,10 +93,6 @@ public String show(String name) { */ @Operation(Kind.MUTATION) public void delete(String name) { - TmuxVersion running = server.version(); - if (!running.atLeast(EXACT_NAMED_DELETE)) { - throw new UnsupportedFeatureException("deleting a buffer by exact name", EXACT_NAMED_DELETE, running); - } 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 TargetGoneException("no buffer named '" + name + "'"); diff --git a/libtmux/src/main/java/io/github/libtmux/IncarnationFence.java b/libtmux/src/main/java/io/github/libtmux/IncarnationFence.java index f2d735a64..6286eabcf 100644 --- a/libtmux/src/main/java/io/github/libtmux/IncarnationFence.java +++ b/libtmux/src/main/java/io/github/libtmux/IncarnationFence.java @@ -2,6 +2,7 @@ import io.github.libtmux.exception.TargetGoneException; import io.github.libtmux.internal.CommandStrings; +import io.github.libtmux.internal.TmuxFeatures; import io.github.libtmux.snapshot.ServerSnapshot; import io.github.libtmux.snapshot.WindowContext; import io.github.libtmux.transport.CommandResult; @@ -66,17 +67,26 @@ CommandResult guarded(ServerSnapshot snapshot, String command) { } CommandResult guarded(ServerSnapshot snapshot, String command, String input) { + return guarded(snapshot, command, input, TmuxFeatures.Intent.literal(List.of())); + } + + CommandResult guarded(ServerSnapshot snapshot, String command, String input, TmuxFeatures.Intent intent) { long pid = snapshot.serverPid() .orElseThrow(() -> new IllegalStateException("a live handle has no server process identity")); - return guarded(pid, incarnation(pid, snapshot.serverStartTime()), command, input); + return guarded(pid, incarnation(pid, snapshot.serverStartTime()), command, input, intent); } CommandResult guarded(long pid, String condition, String command, String input) { + return guarded(pid, condition, command, input, TmuxFeatures.Intent.literal(List.of())); + } + + CommandResult guarded(long pid, String condition, String command, String input, TmuxFeatures.Intent intent) { String stale = "libtmux-stale-handle-" + pid; CommandResult result = server.cmd( List.of("if-shell", "-F", condition, command, stale), server.config().defaultTimeout(), - input); + input, + intent); if (!result.succeeded() && result.stderr().stream().anyMatch(line -> line.contains(stale))) { throw new TargetGoneException("the tmux server this handle belonged to has ended"); } @@ -91,8 +101,11 @@ CommandResult run(ServerSnapshot snapshot, WindowContext expected, List String stale = "libtmux-stale-winlink-" + pid + "-" + expected.window().value(); String condition = "#{&&:" + incarnation(pid, snapshot.serverStartTime()) + ",#{==:#{window_id}," + expected.window().value() + "}}"; - CommandResult result = - server.cmd(List.of("if-shell", "-F", "-t", target, condition, CommandStrings.stringify(argv), stale)); + CommandResult result = server.cmd( + List.of("if-shell", "-F", "-t", target, condition, CommandStrings.stringify(argv), stale), + server.config().defaultTimeout(), + "", + TmuxFeatures.Intent.literal(List.of(argv))); if (!result.succeeded() && result.stderr().stream().anyMatch(line -> line.contains(stale))) { throw new TargetGoneException("window " + expected.window() + " no longer exists here"); } diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index d31ffacab..2d1896bb2 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -297,7 +297,12 @@ public Server server() { return server; } - /** Collects commands fenced to the server incarnation that produced this pane. */ + /** + * Collects commands fenced to the server incarnation that produced this pane. + * + *

Nested tmux command strings ({@code if-shell}, {@code run-shell -C}, hooks, brace blocks, + * {@code source-file}) are not checked; do not nest a command this library refuses. + */ @Operation(Kind.CAPTURED) public Batch batch() { return server.batch(snapshot); @@ -696,22 +701,12 @@ ServerIdentity identity() { /** * Moves this pane into a window of its own, leaving tmux to name it. * - *

On tmux 3.7 the name is supplied rather than left to tmux, because letting tmux choose it - * there ends the server and every session on it. The name supplied is the one tmux would have - * chosen, so the result is the same window either way. + * @throws UnsupportedFeatureException on exact tmux 3.7, whose unnamed break can end the server; + * use {@link #breakOut(String)} or tmux 3.7a or newer */ @Operation(Kind.MUTATION) public Window breakOut() { - // The name tmux would have chosen anyway, supplied only so 3.7 does not have to choose it. - // Read live rather than from the capture: a pane's command changes as its shell starts, and - // a captured value can be older than the break by any amount. - return breakNamed(Optional.empty(), currentCommandNow()); - } - - /** What tmux says is running here at this moment, rather than when the capture was taken. */ - private String currentCommandNow() { - String reported = expand("#{pane_current_command}"); - return reported.isEmpty() ? state.currentCommand() : reported; + return breakNamed(Optional.empty()); } /** @@ -820,23 +815,28 @@ public void stopPiping() { @Operation(Kind.MUTATION) public Window breakOut(String windowName) { Objects.requireNonNull(windowName, "windowName"); - return breakNamed(Optional.of(windowName), windowName); + return breakNamed(Optional.of(windowName)); } - /** - * @param wanted the name the caller asked for, if they asked - * @param supplied the name to hand tmux, which is never absent because 3.7 crashes without one - */ - 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(snapshot, argv).stdout().get(0)); + private Window breakNamed(Optional wanted) { + TmuxVersion running = server.version(snapshot); + if (wanted.isEmpty() && running.sameRelease(BREAK_PANE_NAMING_BROKEN)) { + throw new UnsupportedFeatureException( + "unnamed break-pane is unreliable on tmux 3.7; use breakOut(name) or tmux 3.7a+"); + } + List argv = new ArrayList<>(List.of("break-pane", "-d")); + wanted.ifPresent(name -> argv.addAll(List.of("-n", name))); + argv.addAll(List.of("-s", state.id().value())); + List> commands = + List.of(argv, List.of("display-message", "-p", "-t", state.id().value(), BROKEN_OUT.template())); + List fields = BROKEN_OUT.split(server.runBreak(snapshot, commands, running, wanted.isPresent()) + .stdout() + .getFirst()); WindowContext created = new WindowContext( new SessionId(fields.get(0)), new WindowIndex(Integer.parseInt(fields.get(2))), new WindowId(fields.get(1))); - if (server.version(snapshot).sameRelease(BREAK_PANE_NAMING_BROKEN)) { + if (running.sameRelease(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), "--", TmuxFormats.literal(name)))); @@ -958,10 +958,6 @@ public void paste(String 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 UnsupportedFeatureException("pasting text", Buffers.EXACT_NAMED_DELETE, running); - } String buffer = "libtmux-paste-" + UUID.randomUUID(); // Recorded before dispatch: a wait already watching this pane must never see the pasted // content on screen before the record that discounts it exists. Rolled back below if the @@ -981,7 +977,8 @@ public void paste(String text) { "-b", buffer, "-t", - state.id().value()))); + state.id().value())), + true); } catch (RuntimeException failure) { settleFailure(recorded, failure); try { @@ -1011,25 +1008,23 @@ public void paste(String text, Runnable beforePaste) { 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 UnsupportedFeatureException("pasting text", Buffers.EXACT_NAMED_DELETE, running); - } String buffer = "libtmux-paste-" + UUID.randomUUID(); try { - server.runTogether(snapshot, text, List.of(List.of("load-buffer", "-b", buffer, "-"))); + server.runTogether(snapshot, text, List.of(List.of("load-buffer", "-b", buffer, "-")), true); beforePaste.run(); PaneEcho.Recorded recorded = server.echo().recordLiteral(identity(), state.id(), text); try { - server.run( + server.runTogether( snapshot, - List.of( + "", + List.of(List.of( "paste-buffer", "-d", "-b", buffer, "-t", - state.id().value())); + state.id().value())), + true); } catch (RuntimeException failure) { settleFailure(recorded, failure); throw failure; diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index a585d03e8..628fbdfd3 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -16,6 +16,7 @@ import io.github.libtmux.format.RowFormat; import io.github.libtmux.internal.CommandStrings; import io.github.libtmux.internal.ErrorText; +import io.github.libtmux.internal.TmuxFeatures; import io.github.libtmux.internal.support.Versions; import io.github.libtmux.query.FilterExpr; import io.github.libtmux.query.TmuxFilters; @@ -448,6 +449,9 @@ private static void awaitExit(List reported, Duration timeout) { * *

Each operation gets its own outcome. tmux discards a group after the first failure, so a * single exit status cannot say which command failed or which never ran. + * + *

Nested tmux command strings ({@code if-shell}, {@code run-shell -C}, hooks, brace blocks, + * {@code source-file}) are not checked; do not nest a command this library refuses. */ @Operation(Kind.CAPTURED) public Batch batch() { @@ -459,6 +463,9 @@ public Batch batch() { * *

tmux moves its own current target as a group runs, so a chain needs no round trip to learn * the id of a window or pane it just created. + * + *

Nested tmux command strings ({@code if-shell}, {@code run-shell -C}, hooks, brace blocks, + * {@code source-file}) are not checked; do not nest a command this library refuses. */ @Operation(Kind.CAPTURED) public CommandChain chain() { @@ -491,7 +498,7 @@ CommandResult printed(@Nullable ServerSnapshot snapshot, List argv) { List> group = PrintedText.framed(argv); CommandResult result = snapshot == null ? execute(request(group, config.defaultTimeout(), "")) - : fence.guarded(snapshot, CommandStrings.group(group)); + : fence.guarded(snapshot, CommandStrings.group(group), "", TmuxFeatures.Intent.literal(group)); return PrintedText.unwrap(result); } @@ -841,6 +848,11 @@ public ServerConfig config() { /** * Runs one tmux command against this server. * + *

A command this release of tmux mishandles is refused with {@link UnsupportedFeatureException} + * before it is sent. Nested tmux command strings ({@code if-shell}, {@code run-shell -C}, + * hooks, brace blocks, {@code source-file}) are not checked; do not nest a command this library + * refuses. + * * @param argv the tmux command and its arguments, each already a separate element * @return the result, in which a nonzero exit is data rather than a failure */ @@ -849,13 +861,23 @@ public CommandResult cmd(String... argv) { return cmd(List.of(argv), config.defaultTimeout()); } - /** Runs one tmux command against this server. */ + /** + * Runs one tmux command against this server. + * + *

Nested tmux command strings ({@code if-shell}, {@code run-shell -C}, hooks, brace blocks, + * {@code source-file}) are not checked; do not nest a command this library refuses. + */ @Operation(Kind.MUTATION) public CommandResult cmd(List argv) { return cmd(argv, config.defaultTimeout()); } - /** Runs one tmux command against this server, overriding the configured deadline. */ + /** + * Runs one tmux command against this server, overriding the configured deadline. + * + *

Nested tmux command strings ({@code if-shell}, {@code run-shell -C}, hooks, brace blocks, + * {@code source-file}) are not checked; do not nest a command this library refuses. + */ @Operation(Kind.MUTATION) public CommandResult cmd(List argv, Duration timeout) { return cmd(argv, timeout, ""); @@ -866,6 +888,11 @@ CommandResult cmd(List argv, Duration timeout, String input) { return execute(request(List.of(argv), timeout, input)); } + CommandResult cmd(List argv, Duration timeout, String input, TmuxFeatures.Intent intent) { + requireOpen(); + return Versions.execute(transport, request(List.of(argv), timeout, input), false, intent); + } + private CommandResult execute(CommandRequest request) { requireOpen(); return Versions.execute(transport, request, false); @@ -1320,12 +1347,13 @@ public CommandResult run(List argv) { } CommandResult cmd(ServerSnapshot snapshot, List argv) { - return fence.guarded(snapshot, CommandStrings.stringify(argv)); + return fence.guarded(snapshot, CommandStrings.stringify(argv), "", TmuxFeatures.Intent.literal(List.of(argv))); } /** A batch whose every command is refused once this handle's server has been replaced. */ Batch batch(ServerSnapshot snapshot) { - return new Batch(commands -> fence.guarded(snapshot, CommandStrings.group(commands), "")); + return new Batch(commands -> + fence.guarded(snapshot, CommandStrings.group(commands), "", TmuxFeatures.Intent.literal(commands))); } /** @@ -1337,7 +1365,8 @@ Batch batch(ServerSnapshot snapshot) { */ Batch batch(long pid, long startTime) { String condition = IncarnationFence.incarnation(pid, java.util.OptionalLong.of(startTime)); - return new Batch(commands -> fence.guarded(pid, condition, CommandStrings.group(commands), "")); + return new Batch(commands -> fence.guarded( + pid, condition, CommandStrings.group(commands), "", TmuxFeatures.Intent.literal(commands))); } CommandResult run(ServerSnapshot snapshot, List argv) { @@ -1361,7 +1390,16 @@ CommandResult runTogether(ServerSnapshot snapshot, List> 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 = fence.guarded(snapshot, CommandStrings.group(commands), input); + return runTogether(snapshot, input, commands, false); + } + + CommandResult runTogether( + ServerSnapshot snapshot, String input, List> commands, boolean namedBufferCleanup) { + CommandResult result = fence.guarded( + snapshot, + CommandStrings.group(commands), + input, + new TmuxFeatures.Intent(commands, false, namedBufferCleanup)); if (!result.succeeded()) { String verbs = commands.stream().map(argv -> argv.get(0)).collect(Collectors.joining(" then ")); throw failed(verbs, result); @@ -1369,6 +1407,16 @@ CommandResult runTogether(ServerSnapshot snapshot, String input, List> commands, TmuxVersion version, boolean named) { + long pid = snapshot.serverPid().orElseThrow(); + String condition = "#{&&:" + IncarnationFence.incarnation(pid, snapshot.serverStartTime()) + ",#{==:#{version}," + + version + "}}"; + CommandResult result = fence.guarded( + pid, condition, CommandStrings.group(commands), "", new TmuxFeatures.Intent(commands, named, false)); + if (!result.succeeded()) throw failed(commands.getFirst().getFirst(), result); + return result; + } + /** As {@link #run(ServerSnapshot, List)}, refused as well once the window has been replaced. */ CommandResult run(ServerSnapshot snapshot, WindowContext expected, List argv) { return fence.run(snapshot, expected, argv); 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 0213fafec..a26220133 100644 --- a/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java +++ b/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java @@ -315,13 +315,23 @@ private void requestJsonLayouts() { } } - /** Runs one command and waits for its reply. */ + /** + * Runs one command and waits for its reply. + * + *

Nested tmux command strings ({@code if-shell}, {@code run-shell -C}, hooks, brace blocks, + * {@code source-file}) are not checked; do not nest a command this library refuses. + */ @Operation(Kind.MUTATION) public ControlReply send(String... argv) { return send(List.of(argv), DEFAULT_TIMEOUT); } - /** Runs one command and waits for its reply. */ + /** + * Runs one command and waits for its reply. + * + *

Nested tmux command strings ({@code if-shell}, {@code run-shell -C}, hooks, brace blocks, + * {@code source-file}) are not checked; do not nest a command this library refuses. + */ @Operation(Kind.MUTATION) public ControlReply send(List argv) { return send(argv, DEFAULT_TIMEOUT); @@ -330,6 +340,9 @@ public ControlReply send(List argv) { /** * Runs one command and waits for its reply. * + *

Nested tmux command strings ({@code if-shell}, {@code run-shell -C}, hooks, brace blocks, + * {@code source-file}) are not checked; do not nest a command this library refuses. + * * @param argv the command, its arguments already separate elements * @param timeout how long to wait for tmux to answer * @return tmux's reply diff --git a/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java b/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java new file mode 100644 index 000000000..abcb224e2 --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java @@ -0,0 +1,131 @@ +package io.github.libtmux.internal; + +import io.github.libtmux.Layouts; +import io.github.libtmux.TmuxVersion; +import io.github.libtmux.exception.UnsupportedFeatureException; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** Feature boundaries for literal commands and the commands hidden by managed guards. */ +public final class TmuxFeatures { + private static final TmuxVersion EXACT_BUFFER_LOOKUP = new TmuxVersion(3, 4, ""); + private static final TmuxVersion LAYOUT_ERROR_REPORTING = new TmuxVersion(3, 4, ""); + private static final TmuxVersion SPLIT_PERCENT_BROKEN = new TmuxVersion(3, 4, ""); + private static final TmuxVersion BREAK_BROKEN = new TmuxVersion(3, 7, ""); + + private TmuxFeatures() {} + + /** Original commands remain separate from their encoded incarnation guard. */ + public record Intent(List> commands, boolean repairedBreak, boolean namedBufferCleanup) { + public Intent { + commands = commands.stream().map(List::copyOf).toList(); + } + + public static Intent literal(List> commands) { + return new Intent(commands, false, false); + } + } + + public static void require(TmuxVersion version, Intent intent) { + if (intent.namedBufferCleanup() && !version.atLeast(EXACT_BUFFER_LOOKUP)) { + throw new UnsupportedFeatureException("managed paste buffer cleanup", EXACT_BUFFER_LOOKUP, version); + } + for (List command : intent.commands()) { + if (command.isEmpty()) continue; + String verb = command.getFirst(); + if (matches(verb, "delete-buffer", "deleteb", 3)) { + if (flags(command, "b").contains('b') && !version.atLeast(EXACT_BUFFER_LOOKUP)) { + throw new UnsupportedFeatureException( + "deleting a buffer by exact name", EXACT_BUFFER_LOOKUP, version); + } + } else if (matches(verb, "set-buffer", "setb", 5)) { + Set flags = flags(command, "btn"); + if (flags.contains('b') && flags.contains('n') && !version.atLeast(EXACT_BUFFER_LOOKUP)) { + throw new UnsupportedFeatureException( + "renaming a buffer by exact name", EXACT_BUFFER_LOOKUP, version); + } + } else if (matches(verb, "split-window", "splitw", 2)) { + if (version.sameRelease(SPLIT_PERCENT_BROKEN) + && flags(command, "ceFlpsStRmxyXYL").contains('p')) { + throw new UnsupportedFeatureException( + "tmux 3.4 split-window -p is unreliable; use -l with a percentage"); + } + } else if (matches(verb, "break-pane", "breakp", 2) + && version.sameRelease(BREAK_BROKEN) + && !intent.repairedBreak()) { + throw new UnsupportedFeatureException( + "literal break-pane and unnamed breaks are unreliable on tmux 3.7; use Pane.breakOut(name) or tmux 3.7a+"); + } else if (matches(verb, "select-layout", "selectl", 8) && !version.atLeast(LAYOUT_ERROR_REPORTING)) { + Set flags = new HashSet<>(); + int operand = options(command, "t", flags); + if (operand < command.size() && !flags.contains('n') && !flags.contains('p') && !flags.contains('E')) { + String layout = command.get(operand); + // 3.3a ends the server on an operand that is neither a layout name nor + // checksummed. A checksummed string is tmux's to accept or report, geometry + // included, so only the checksum is checked here. + if (!checksummed(layout)) { + Layouts.require(layout, version); + if (flags.contains('o') + && Layouts.builtIn(layout, version).isPresent()) { + throw new IllegalArgumentException( + "select-layout -o requires a layout tmux wrote: " + layout); + } + } + } + } + } + } + + /** Whether the string leads with the four-digit checksum tmux computes over the rest. */ + private static boolean checksummed(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; + } + for (int index = 0; index < 4; index++) { + if (Character.digit(layout.charAt(index), 16) < 0) { + return false; + } + } + int checksum = 0; + for (int index = 5; index < layout.length(); index++) { + checksum = ((checksum >> 1) + ((checksum & 1) << 15) + layout.charAt(index)) & 0xffff; + } + return checksum == declared; + } + + private static boolean matches(String verb, String full, String alias, int minimumPrefix) { + return verb.equals(alias) || (verb.length() >= minimumPrefix && full.startsWith(verb)); + } + + /** Stops at values and --, so names such as -p or -n are never interpreted as flags. */ + private static Set flags(List command, String valued) { + Set result = new HashSet<>(); + options(command, valued, result); + return result; + } + + private static int options(List command, String valued, Set result) { + int index = 1; + for (; index < command.size(); index++) { + String word = command.get(index); + if (word.equals("--")) return index + 1; + if (!word.startsWith("-") || word.equals("-")) break; + for (int offset = 1; offset < word.length(); offset++) { + char flag = word.charAt(offset); + result.add(flag); + if (valued.indexOf(flag) >= 0) { + if (offset + 1 == word.length()) index++; + break; + } + } + } + return index; + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/internal/support/Versions.java b/libtmux/src/main/java/io/github/libtmux/internal/support/Versions.java index 47dd3b190..f36e2d024 100644 --- a/libtmux/src/main/java/io/github/libtmux/internal/support/Versions.java +++ b/libtmux/src/main/java/io/github/libtmux/internal/support/Versions.java @@ -9,6 +9,7 @@ import io.github.libtmux.exception.TargetGoneException; import io.github.libtmux.exception.UnsupportedFeatureException; import io.github.libtmux.internal.ErrorText; +import io.github.libtmux.internal.TmuxFeatures; import io.github.libtmux.transport.CommandRequest; import io.github.libtmux.transport.CommandResult; import io.github.libtmux.transport.ControlCarrier; @@ -62,6 +63,12 @@ private static final class Replaced extends RuntimeException { /** Checks the live daemon before dispatch and fences the requested commands on that version. */ public static CommandResult execute(TmuxTransport transport, CommandRequest request, boolean waiting) { + return execute(transport, request, waiting, TmuxFeatures.Intent.literal(request.commands())); + } + + /** As above, refusing first any optional feature the checked version cannot safely run. */ + public static CommandResult execute( + TmuxTransport transport, CommandRequest request, boolean waiting, TmuxFeatures.Intent intent) { long started = System.nanoTime(); if (request.commands().equals(List.of(List.of("-V"))) || request.commands().equals(List.of(List.of("-h")))) { @@ -70,7 +77,7 @@ public static CommandResult execute(TmuxTransport transport, CommandRequest requ String known = recall(transport, request); if (known != null) { try { - CommandResult result = dispatchChecked(transport, request, known, started, waiting); + CommandResult result = dispatchChecked(transport, request, known, started, waiting, intent); // A daemon that has gone answers without running anything, so asking again is safe. if (!gone(result)) return result; } catch (Replaced replaced) { @@ -82,7 +89,7 @@ public static CommandResult execute(TmuxTransport transport, CommandRequest requ // read once more. A refused version surfaces from that read as the version error it is. for (int attempt = 0; ; attempt++) { try { - return checkVersion(transport, request, waiting, started); + return checkVersion(transport, request, waiting, started, intent); } catch (Replaced replaced) { forget(transport, request); if (attempt == 1) { @@ -93,7 +100,11 @@ public static CommandResult execute(TmuxTransport transport, CommandRequest requ } private static CommandResult checkVersion( - TmuxTransport transport, CommandRequest request, boolean waiting, long started) { + TmuxTransport transport, + CommandRequest request, + boolean waiting, + long started, + TmuxFeatures.Intent intent) { Probe probe = probe(transport, request); String version; if (probe.version().isPresent()) { @@ -118,13 +129,20 @@ private static CommandResult checkVersion( throw rejected(request, probe.result()); } if (probe.version().isPresent()) { - return dispatchChecked(transport, request, version, started, waiting); + return dispatchChecked(transport, request, version, started, waiting, intent); } + TmuxFeatures.require(TmuxVersion.parse(version), intent); return dispatch(transport, request, guarded(request.commands(), version), remaining(request, started), waiting); } private static CommandResult dispatchChecked( - TmuxTransport transport, CommandRequest request, String version, long started, boolean waiting) { + TmuxTransport transport, + CommandRequest request, + String version, + long started, + boolean waiting, + TmuxFeatures.Intent intent) { + TmuxFeatures.require(TmuxVersion.parse(version), intent); CommandRequest live = new CommandRequest( withoutStarting(request.endpoint()), request.commands(), request.timeout(), request.input()); return dispatch(transport, live, guarded(request.commands(), version), remaining(request, started), waiting); @@ -231,7 +249,7 @@ private static UnsupportedFeatureException refused(String reason) { } /** Checks through the carrier without taking ownership of the caller's carrier. */ - public static void requireCarrier(ControlCarrier carrier, List endpoint, Duration timeout) { + public static TmuxVersion requireCarrier(ControlCarrier carrier, List endpoint, Duration timeout) { String marker = marker(); CommandRequest request = new CommandRequest(withoutStarting(endpoint), List.of(probeCommand(marker)), timeout, "", true); @@ -243,7 +261,7 @@ public static void requireCarrier(ControlCarrier carrier, List endpoint, } Optional version = answer(result, marker, endpoint.getFirst()); if (version.isEmpty()) throw rejected(request, result); - require(version.get()); + return require(version.get()); } private static boolean cleanup(CommandRequest request) { From 4c3440d82f1e807a821d91f7d9a2232d2103d9f2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 04:28:24 -0500 Subject: [PATCH 04/18] Control(fix): Qualify the daemon and pin its socket before control why: Control clients are only qualified on stable tmux 3.7 and newer; a short-lived control attach crashes 3.2a and 3.6b servers. Until now the version check ran through the carrier and the later `-C` attach named the socket path again, so a daemon replaced in between could receive the attach, and a long socket path could exceed sun_path. what: - ControlAcquisition decides first, with the hook-free version probe, and refuses a daemon below a stable 3.7 (development builds and release candidates included) before any control client exists. It then reads the daemon identity, pins the socket, rereads the identity through the pin and attaches with `-N` to the pinned alias. One deadline covers qualification and attach. - ControlCarrier gains pinSocket; the default refuses, so custom carriers must retain the socket in their own realm. The process transport and the local carrier use LocalSocketPin: a private 0700 directory on the socket's filesystem holding a hard link, under the 103-byte limit, removed on close, EOF or failed attach. - ControlClient.attach, attachUnfenced and Server.control go through it; Server.control refuses before its identity read. Literal commands sent over a control line pass the same feature guards. - Subprocess and polling paths are untouched below 3.7. - Tests: control-positive cases carry the control tag and the matrix runs them from 3.7; every lane asserts the refusal and that no client attached. Cases that only needed some attached client use NativeClient. Fakes (FakeTmux, the Kotlin and Cats control scripts, ControlClientTest) answer the probe, the identity read and the pin. Verified on real tmux 3.3a and 3.7 with ControlAcquisitionIntegrationTest, ControlFloorIntegrationTest and the control suites. --- .../kotlin/libtmux.tmux-matrix.gradle.kts | 8 +- .../it/BuffersAndClientIntegrationTest.java | 22 +- .../it/ClientOperationsIntegrationTest.java | 25 ++- .../it/ControlAcquisitionIntegrationTest.java | 170 +++++++++++++++ .../it/ControlFloorIntegrationTest.java | 82 ++++++++ .../it/ControlModeIntegrationTest.java | 8 + .../it/ControlSoakIntegrationTest.java | 8 + .../io/github/libtmux/it/ControlSupport.java | 18 ++ .../it/ControlWatchIntegrationTest.java | 8 + .../libtmux/it/NavigationIntegrationTest.java | 5 +- .../it/QuotingPropertyIntegrationTest.java | 104 ++++++--- .../it/ServerControlIntegrationTest.java | 69 +++--- .../it/ServerMirrorIntegrationTest.java | 8 + .../it/ServerScriptingIntegrationTest.java | 19 +- .../it/VersionCheckLaneIntegrationTest.java | 8 +- .../scaladsl/ControlObservationSuite.scala | 28 +++ .../libtmux/scaladsl/JavaBoundarySuite.scala | 4 +- .../libtmux/scaladsl/LiveStateSuite.scala | 8 + .../scaladsl/ObservationStressSuite.scala | 4 + .../libtmux/scaladsl/QueryInteropSuite.scala | 5 +- .../scaladsl/cats/CatsLiveServerSuite.scala | 28 ++- .../cats/CatsObservationStressSuite.scala | 4 + .../scaladsl/fixture/OwnedTmuxSuite.scala | 4 + .../libtmux/scaladsl/ox/OxFlowsSuite.scala | 12 ++ .../libtmux/scaladsl/fixture/OwnedTmux.scala | 12 +- .../io/github/libtmux/junit5/FakeTmux.java | 80 +++++-- .../github/libtmux/kotlin/ControlSupport.kt | 13 ++ .../libtmux/kotlin/FakeControlClients.kt | 54 +++-- .../io/github/libtmux/kotlin/LiveStateTest.kt | 6 + .../libtmux/kotlin/ReadmeExamplesTest.kt | 5 + .../io/github/libtmux/kotlin/WaitsTest.kt | 17 +- .../scaladsl/cats/ObservationSuite.scala | 73 ++++++- .../main/java/io/github/libtmux/Server.java | 2 + .../github/libtmux/control/ControlClient.java | 199 ++++++++++-------- .../libtmux/internal/ControlAcquisition.java | 184 ++++++++++++++++ .../libtmux/internal/LocalSocketPin.java | 95 +++++++++ .../libtmux/transport/ControlCarrier.java | 28 +++ .../libtmux/transport/ProcessTransport.java | 18 +- .../libtmux/ControlFloorVectorsTest.java | 31 +++ .../java/io/github/libtmux/ServerTest.java | 6 +- .../libtmux/control/ControlClientTest.java | 76 +++++-- 41 files changed, 1297 insertions(+), 261 deletions(-) create mode 100644 integration-tests/src/test/java/io/github/libtmux/it/ControlAcquisitionIntegrationTest.java create mode 100644 integration-tests/src/test/java/io/github/libtmux/it/ControlFloorIntegrationTest.java create mode 100644 integration-tests/src/test/java/io/github/libtmux/it/ControlSupport.java create mode 100644 libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/ControlSupport.kt create mode 100644 libtmux/src/main/java/io/github/libtmux/internal/ControlAcquisition.java create mode 100644 libtmux/src/main/java/io/github/libtmux/internal/LocalSocketPin.java create mode 100644 libtmux/src/test/java/io/github/libtmux/ControlFloorVectorsTest.java diff --git a/build-logic/conventions/src/main/kotlin/libtmux.tmux-matrix.gradle.kts b/build-logic/conventions/src/main/kotlin/libtmux.tmux-matrix.gradle.kts index 112a89127..06077ede2 100644 --- a/build-logic/conventions/src/main/kotlin/libtmux.tmux-matrix.gradle.kts +++ b/build-logic/conventions/src/main/kotlin/libtmux.tmux-matrix.gradle.kts @@ -35,7 +35,13 @@ fun registerLane(lane: String, rejection: Boolean = false) = // "locale" joins the excludes for the same reason "carrier" is here: it needs a JVM // started differently — one whose platform encoding is not UTF-8 — so running it in an // ordinary fork asserts nothing and fails saying so. - useJUnitPlatform { excludeTags("fixture", "carrier", "benchmark", "locale") } + useJUnitPlatform { + excludeTags("fixture", "carrier", "benchmark", "locale") + // Ordinary 3.3a+ operations still run; managed control has its own 3.7 floor. + if (lane in listOf("3.3a", "3.4", "3.5", "3.6")) { + excludeTags("control") + } + } // By class, not by tag: the Scala suites run under another engine that ignores tags. if (rejection) { 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 fe8e3c9cb..2f31d7699 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 @@ -11,7 +11,6 @@ import io.github.libtmux.Server; import io.github.libtmux.Session; import io.github.libtmux.TmuxVersion; -import io.github.libtmux.control.ControlClient; import io.github.libtmux.exception.LibTmuxException; import io.github.libtmux.exception.ServerUnavailableException; import io.github.libtmux.exception.TargetGoneException; @@ -238,14 +237,15 @@ void sourcingAFileRunsTheCommandsInIt(Server server, @TempDir Path directory) th // --------------------------------------------------------------------------------- client /** - * The fixture session is detached, so a client has to be made. A control client attaches, which - * is what gives tmux a client to report at all. + * The fixture session is detached, so an ordinary terminal client supplies the attachment + * these reads report. */ @Test void anAttachedClientReportsWhatItIsLookingAt(Server server) throws Exception { Session session = server.sessions().get(0); - try (ControlClient attached = server.control(session)) { - assertTrue(Await.until(() -> !server.clients().isEmpty()), "the control client never appeared as a client"); + try (NativeClient attached = NativeClient.attach(session)) { + assertTrue( + Await.until(() -> !server.clients().isEmpty()), "the ordinary client never appeared as a client"); Client client = server.clients().get(0); ClientAttachment looking = client.attachment().orElseThrow(); @@ -256,15 +256,17 @@ void anAttachedClientReportsWhatItIsLookingAt(Server server) throws Exception { looking.activeWindow().id(), "the window it reports is the session's active one"); assertTrue(looking.activePane().active(), "and the pane is that window's active pane"); - assertEquals(attached.send("display-message", "-p", "ok").lines(), List.of("ok")); + assertTrue(attached.isAlive()); + assertEquals( + List.of("ok"), server.cmd("display-message", "-p", "ok").stdout()); } } @Test void fetchingAnAttachmentTakesAFreshLook(Server server) throws Exception { Session session = server.sessions().get(0); - try (ControlClient attached = server.control(session)) { - assertTrue(attached.send("display-message", "-p", "ready").succeeded()); + try (NativeClient attached = NativeClient.attach(session)) { + assertTrue(attached.isAlive()); assertTrue(Await.until(() -> !server.clients().isEmpty())); Client client = server.clients().get(0); String before = client.attachment().orElseThrow().activeWindow().name(); @@ -288,8 +290,8 @@ void aClientThatHasGoneIsGoneToEveryRead(Server server) throws Exception { Set before = server.clients().stream().map(Client::name).collect(Collectors.toSet()); Client client; - try (ControlClient attached = server.control(session)) { - assertTrue(attached.send("display-message", "-p", "ready").succeeded()); + try (NativeClient attached = NativeClient.attach(session)) { + assertTrue(attached.isAlive()); assertTrue(Await.until(() -> appeared(server, before).isPresent()), "no client ever attached"); client = appeared(server, before).orElseThrow(); } 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 a617dbd89..b57fdda6b 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 @@ -9,7 +9,6 @@ import io.github.libtmux.Server; import io.github.libtmux.Session; import io.github.libtmux.SessionId; -import io.github.libtmux.control.ControlClient; import io.github.libtmux.exception.LibTmuxException; import io.github.libtmux.junit5.TmuxExtension; import java.util.List; @@ -38,8 +37,8 @@ void aClientCanBeDetachedAndTheSessionSurvives(Server server) throws Exception { // and detaching must take that one rather than whichever tmux happens to list first. Set before = server.clients().stream().map(Client::name).collect(Collectors.toSet()); - try (ControlClient attached = server.control(session)) { - assertTrue(attached.send("display-message", "-p", "ready").succeeded()); + try (NativeClient attached = NativeClient.attach(session)) { + assertTrue(attached.isAlive()); assertTrue(Await.until(() -> appeared(server, before).isPresent()), "no client ever attached"); Client client = appeared(server, before).orElseThrow(); @@ -58,8 +57,8 @@ void aClientCanBeMovedToAnotherSession(Server server) throws Exception { Session first = server.sessions().get(0); Session second = server.newSession(s -> s.named("elsewhere")); - try (ControlClient attached = server.control(first)) { - assertTrue(attached.send("display-message", "-p", "ready").succeeded()); + try (NativeClient attached = NativeClient.attach(first)) { + assertTrue(attached.isAlive()); assertTrue(Await.until(() -> !server.clients().isEmpty())); Client client = server.clients().get(0); @@ -79,8 +78,8 @@ void aClientCanBeMovedToAnotherSession(Server server) throws Exception { void redrawingIsNotTheSameAsRecapturing(Server server) throws Exception { Session session = server.sessions().get(0); - try (ControlClient attached = server.control(session)) { - assertTrue(attached.send("display-message", "-p", "ready").succeeded()); + try (NativeClient attached = NativeClient.attach(session)) { + assertTrue(attached.isAlive()); assertTrue(Await.until(() -> !server.clients().isEmpty())); Client client = server.clients().get(0); @@ -94,10 +93,10 @@ void redrawingIsNotTheSameAsRecapturing(Server server) throws Exception { void detachingEveryOtherClientLeavesThisOneAttached(Server server) throws Exception { Session session = server.sessions().get(0); - try (ControlClient one = server.control(session); - ControlClient two = server.control(session)) { - assertTrue(one.send("display-message", "-p", "ready").succeeded()); - assertTrue(two.send("display-message", "-p", "ready").succeeded()); + try (NativeClient one = NativeClient.attach(session); + NativeClient two = NativeClient.attach(session)) { + assertTrue(one.isAlive()); + assertTrue(two.isAlive()); assertTrue(Await.until(() -> server.clients().size() >= 2), "two clients never attached"); Client survivor = server.clients().get(0); @@ -119,8 +118,8 @@ void onlyTheSessionsSomebodyIsAttachedToAreListed(Server server) throws Exceptio assertEquals(List.of(), server.attachedSessions(), "nothing is attached yet"); - try (ControlClient attached = server.control(watched)) { - assertTrue(attached.send("display-message", "-p", "ready").succeeded()); + try (NativeClient attached = NativeClient.attach(watched)) { + assertTrue(attached.isAlive()); assertTrue(Await.until(() -> !server.attachedSessions().isEmpty()), "no session ever became attached"); List listed = diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ControlAcquisitionIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ControlAcquisitionIntegrationTest.java new file mode 100644 index 000000000..96f05b579 --- /dev/null +++ b/integration-tests/src/test/java/io/github/libtmux/it/ControlAcquisitionIntegrationTest.java @@ -0,0 +1,170 @@ +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; + +import io.github.libtmux.Server; +import io.github.libtmux.ServerConfig; +import io.github.libtmux.ServerEndpoint; +import io.github.libtmux.control.ControlClient; +import io.github.libtmux.exception.LibTmuxException; +import io.github.libtmux.internal.ControlAcquisition; +import io.github.libtmux.junit5.NamedServerFixture; +import io.github.libtmux.junit5.TmuxExtension; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.ControlCarrier; +import io.github.libtmux.transport.ProcessTransport; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** Native acquisition must retain its socket before starting a control process. */ +@Tag("control") +@ExtendWith(TmuxExtension.class) +final class ControlAcquisitionIntegrationTest { + private static final Duration LIMIT = Duration.ofSeconds(5); + + @BeforeEach + void requireControl(Server server) { + ControlSupport.require(server); + } + + @Test + void replacingTheOriginalSocketCannotAcquireTheReplacement(Server server) throws Exception { + var snapshot = server.snapshot(); + var session = server.sessions().getFirst(); + AtomicReference alias = new AtomicReference<>(); + AtomicBoolean attempted = new AtomicBoolean(); + try (ProcessTransport transport = new ProcessTransport(); + Server replacement = Server.open(server.config())) { + ControlCarrier local = transport.controlCarrier().orElseThrow(); + ControlCarrier racing = new ControlCarrier() { + @Override + public PinnedSocket pinSocket(String socket, Duration timeout) throws IOException { + PinnedSocket pin = local.pinSocket(socket, timeout); + alias.set(Path.of(pin.socketPath())); + return pin; + } + + @Override + public Process start(List command) throws IOException { + if (command.contains("-C")) { + attempted.set(true); + server.killServer(); + replacement.newSession("replacement"); + replacement + .hooks() + .set("client-attached", List.of("set-option", "-g", "@unexpected-control", "yes")); + } + return local.start(command); + } + }; + try { + assertThrows( + LibTmuxException.class, + () -> ControlClient.attach( + racing, + server.config(), + session.id(), + snapshot.serverPid().orElseThrow(), + snapshot.serverStartTime(), + server.version().toString(), + LIMIT)); + assertTrue(attempted.get(), "the socket was not replaced immediately before -C"); + assertTrue(replacement.isAlive()); + assertTrue(replacement.clients().isEmpty()); + assertTrue( + replacement.globalOptions().get("@unexpected-control").isEmpty(), + "the replacement registered a control client before the post-attach fence"); + assertTrue(Files.notExists(alias.get()), "the refused attachment retained its alias"); + assertTrue(Files.notExists(alias.get().getParent())); + assertTrue( + transport + .execute(CommandRequest.of( + replacement.config().endpointCommand(), + List.of("display-message", "-p", "borrowed"), + LIMIT)) + .succeeded(), + "closing the client closed its borrowed carrier"); + } finally { + replacement.killServer(); + } + } + } + + @Test + void daemonExitRemovesThePinBeforeExplicitClientClose(Server server) throws Exception { + AtomicReference alias = new AtomicReference<>(); + try (ProcessTransport transport = new ProcessTransport()) { + ControlCarrier local = transport.controlCarrier().orElseThrow(); + ControlCarrier counted = new ControlCarrier() { + @Override + public Process start(List command) throws IOException { + return local.start(command); + } + + @Override + public PinnedSocket pinSocket(String socket, Duration timeout) throws IOException { + PinnedSocket pin = local.pinSocket(socket, timeout); + alias.set(Path.of(pin.socketPath())); + return pin; + } + }; + var snapshot = server.snapshot(); + try (ControlClient client = ControlClient.attach( + counted, + server.config(), + server.sessions().getFirst().id(), + snapshot.serverPid().orElseThrow(), + snapshot.serverStartTime(), + server.version().toString(), + LIMIT)) { + assertTrue(Files.exists(alias.get())); + server.killServer(); + assertTrue(Await.until(() -> Files.notExists(alias.get())), "natural EOF leaked the pin"); + assertTrue(Files.notExists(alias.get().getParent())); + assertFalse(client.isAlive()); + } + } + } + + @Test + void aValidLongSocketCanUseAShortAlias(Server fixture) throws Exception { + Path root = Files.createTempDirectory(Path.of("/tmp/libtmux-java-test"), "long-pin-"); + int padding = 100 - root.toString().getBytes(StandardCharsets.UTF_8).length - 3; + Path socket = Files.createDirectory(root.resolve("x".repeat(padding))).resolve("s"); + assertEquals(100, socket.toString().getBytes(StandardCharsets.UTF_8).length); + ServerConfig config = ServerConfig.builder() + .binary(fixture.config().binary()) + .endpoint(ServerEndpoint.socketPath(socket)) + .configFile(Path.of("/dev/null")) + .build(); + try (Server server = Server.open(config)) { + try { + server.newSession("long-socket"); + try (NamedServerFixture owned = NamedServerFixture.own(server, socket, root); + ControlClient client = server.control(server.sessions().getFirst())) { + assertEquals(socket.toRealPath(), owned.socket()); + assertEquals( + List.of("long-path"), + client.send("display-message", "-p", "long-path").lines()); + } + } finally { + server.killServer(); + } + } finally { + Files.deleteIfExists(root); + } + } +} diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ControlFloorIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ControlFloorIntegrationTest.java new file mode 100644 index 000000000..7ba46975b --- /dev/null +++ b/integration-tests/src/test/java/io/github/libtmux/it/ControlFloorIntegrationTest.java @@ -0,0 +1,82 @@ +package io.github.libtmux.it; + +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 io.github.libtmux.Pane; +import io.github.libtmux.Server; +import io.github.libtmux.TmuxVersion; +import io.github.libtmux.control.ControlClient; +import io.github.libtmux.exception.UnsupportedFeatureException; +import io.github.libtmux.internal.ControlAcquisition; +import io.github.libtmux.junit5.TmuxExtension; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** Control has its own floor; below it every lane asserts the refusal, above it the attach works. */ +@ExtendWith(TmuxExtension.class) +final class ControlFloorIntegrationTest { + private static final TmuxVersion CONTROL = new TmuxVersion(3, 7, ""); + + @Test + void controlRequiresItsOwnFloorAndLiteralBreakRequiresRepair(Server server) { + Pane pane = server.panes().getFirst(); + TmuxVersion running = server.version(); + if (!ControlAcquisition.supports(running)) { + assertThrows( + UnsupportedFeatureException.class, + () -> server.control(server.sessions().getFirst())); + assertThrows( + UnsupportedFeatureException.class, + () -> ControlClient.attachUnfenced( + server.config(), server.sessions().getFirst().id())); + assertTrue(server.clients().isEmpty()); + return; + } + try (ControlClient client = server.control(server.sessions().getFirst())) { + assertTrue(client.isAlive()); + if (running.equals(CONTROL)) { + // Every spelling tmux resolves to break-pane: the name, its alias, and each unique + // prefix down to "br", on every path a command can take. + for (String verb : List.of("break-pane", "breakp", "break-p", "break-pa", "break", "brea", "br")) { + assertThrows( + UnsupportedFeatureException.class, + () -> client.send(verb, "-n", "bad", "-s", pane.id().value()), + verb + " over control"); + assertThrows( + UnsupportedFeatureException.class, + () -> server.cmd(verb, "-n", "bad", "-s", pane.id().value()), + verb + " raw"); + assertThrows( + UnsupportedFeatureException.class, + () -> server.batch() + .add(verb, "-d", "-s", pane.id().value()) + .run(), + verb + " in a batch"); + assertThrows( + UnsupportedFeatureException.class, + () -> server.chain() + .then("list-sessions") + .then(verb, "-d", "-s", pane.id().value()) + .run(), + verb + " in a chain"); + assertThrows( + UnsupportedFeatureException.class, + () -> server.cmd(verb, "-d", "-s", pane.id().value()), + "nameless " + verb); + } + assertTrue(client.isAlive()); + assertTrue(server.isAlive()); + } + } + try (ControlClient client = ControlClient.attachUnfenced( + server.config(), server.sessions().getFirst().id())) { + assertEquals( + List.of("roundtrip"), + client.send("display-message", "-p", "roundtrip").lines()); + } + assertTrue(server.isAlive()); + } +} 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 5d059bbbf..be64786f4 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 @@ -27,6 +27,8 @@ import java.util.concurrent.FutureTask; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -37,9 +39,15 @@ * failed and which never ran. Control-mode requests are independent: the request after a failure * still runs, and every reply carries the request that produced it. */ +@Tag("control") @ExtendWith(TmuxExtension.class) final class ControlModeIntegrationTest { + @BeforeEach + void requireControl(Server server) { + ControlSupport.require(server); + } + /** * A control client starts its own process rather than going through the transport, so it logs * through the same seam itself — and, like the transport, writes the verb and never what a caller diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ControlSoakIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ControlSoakIntegrationTest.java index 54bd36c45..0d3718a3d 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/ControlSoakIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/ControlSoakIntegrationTest.java @@ -16,6 +16,8 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -26,9 +28,15 @@ * longer usable, or the transport says how far the request got. Nothing may hang, and the server * must answer afterwards. */ +@Tag("control") @ExtendWith(TmuxExtension.class) final class ControlSoakIntegrationTest { + @BeforeEach + void requireControl(Server server) { + ControlSupport.require(server); + } + private static final int ROUNDS = 5; private static final int SENDERS = 8; private static final int SENDS = 25; diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ControlSupport.java b/integration-tests/src/test/java/io/github/libtmux/it/ControlSupport.java new file mode 100644 index 000000000..dfc044ecb --- /dev/null +++ b/integration-tests/src/test/java/io/github/libtmux/it/ControlSupport.java @@ -0,0 +1,18 @@ +package io.github.libtmux.it; + +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import io.github.libtmux.Server; +import io.github.libtmux.internal.ControlAcquisition; + +/** + * Managed control needs tmux 3.7 or newer. A suite that attaches a control client skips below it; + * {@link ControlFloorIntegrationTest} asserts the refusal there. + */ +final class ControlSupport { + private ControlSupport() {} + + static void require(Server server) { + assumeTrue(ControlAcquisition.supports(server.version()), "managed control needs tmux 3.7 or newer"); + } +} 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 64c393f27..dbb39a531 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 @@ -17,6 +17,8 @@ import java.time.Duration; import java.util.Optional; import java.util.function.Predicate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -27,9 +29,15 @@ * differs, so a client that subscribes does nothing at all between changes. That is what makes * watching a server cost nothing while it is idle, and it is measured here rather than assumed. */ +@Tag("control") @ExtendWith(TmuxExtension.class) final class ControlWatchIntegrationTest { + @BeforeEach + void requireControl(Server server) { + ControlSupport.require(server); + } + @Test void aWindowAppearingIsAnnouncedWithoutAnythingAsking(Server server) throws Exception { Session session = server.sessions().get(0); 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 a140c6052..15f609ad6 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 @@ -11,7 +11,6 @@ import io.github.libtmux.Session; import io.github.libtmux.Window; import io.github.libtmux.WindowId; -import io.github.libtmux.control.ControlClient; import io.github.libtmux.exception.LibTmuxException; import io.github.libtmux.junit5.TmuxExtension; import org.junit.jupiter.api.Test; @@ -86,8 +85,8 @@ void lastWithNowhereToGoBackToIsReported(Server server) { @Test void detachingLeavesTheSessionRunning(Server server) throws Exception { Session session = server.sessions().get(0); - try (ControlClient attached = server.control(session)) { - assertTrue(attached.send("display-message", "-p", "ready").succeeded()); + try (NativeClient attached = NativeClient.attach(session)) { + assertTrue(attached.isAlive()); assertTrue(Await.until(() -> !server.clients().isEmpty())); session.detachClients(); diff --git a/integration-tests/src/test/java/io/github/libtmux/it/QuotingPropertyIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/QuotingPropertyIntegrationTest.java index 2b42878a4..6983916aa 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/QuotingPropertyIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/QuotingPropertyIntegrationTest.java @@ -1,6 +1,8 @@ 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.TmuxVersion; @@ -9,6 +11,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Random; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -35,40 +38,55 @@ final class QuotingPropertyIntegrationTest { private static final String ALPHABET = "abcXYZ019 !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~é中😀"; @Test - void aValueSurvivesTheArgumentVectorAndTheControlLine(Server server) { + void aValueSurvivesTheArgumentVector(Server server) { + checkValues(server, null); + } + + @Test + @Tag("control") + void aValueSurvivesTheControlLine(Server server) { + ControlSupport.require(server); + try (ControlClient client = server.control(server.sessions().getFirst())) { + checkValues(server, client); + } + } + + private static void checkValues(Server server, @org.jspecify.annotations.Nullable ControlClient client) { List lost = new ArrayList<>(); - try (ControlClient client = server.control(server.sessions().get(0))) { - String alphabet = server.version().atLeast(new TmuxVersion(3, 4, "")) - && !server.version().atLeast(new TmuxVersion(3, 6, "")) - ? ALPHABET - : CONTROLS + ALPHABET; - for (String value : values(new Random(20260924L), 200, alphabet)) { - server.globalOptions().set("@vector", value); - String viaVector = server.globalOptions().get("@vector").orElse(""); - if (!viaVector.equals(value)) { - lost.add("vector " + quoted(value) + " -> " + quoted(viaVector)); - } - String viaFormat = server.expand("#{@vector}"); - if (!viaFormat.equals(value)) { - lost.add("format " + quoted(value) + " -> " + quoted(viaFormat)); - } - String viaListing = server.globalOptions().all().getOrDefault("@vector", ""); - if (!viaListing.equals(value)) { - lost.add("listing " + quoted(value) + " -> " + quoted(viaListing)); - } + String alphabet = server.version().atLeast(new TmuxVersion(3, 4, "")) + && !server.version().atLeast(new TmuxVersion(3, 6, "")) + ? ALPHABET + : CONTROLS + ALPHABET; + for (String value : values(new Random(20260924L), 200, alphabet)) { + server.globalOptions().set("@vector", value); + String viaVector = server.globalOptions().get("@vector").orElse(""); + if (!viaVector.equals(value)) { + lost.add("vector " + quoted(value) + " -> " + quoted(viaVector)); + } + String viaFormat = server.expand("#{@vector}"); + if (!viaFormat.equals(value)) { + lost.add("format " + quoted(value) + " -> " + quoted(viaFormat)); + } + String viaListing = server.globalOptions().all().getOrDefault("@vector", ""); + if (!viaListing.equals(value)) { + lost.add("listing " + quoted(value) + " -> " + quoted(viaListing)); + } + if (client != null) { client.send(List.of("set-option", "-g", "@line", value)); String viaLine = server.globalOptions().get("@line").orElse(""); if (!viaLine.equals(value)) { lost.add("line " + quoted(value) + " -> " + quoted(viaLine)); } + } - server.buffers().set("vector", value); - String viaBuffer = server.buffers().show("vector"); - String inBuffer = value.replaceAll("\n+$", ""); - if (!viaBuffer.equals(inBuffer)) { - lost.add("buffer " + quoted(value) + " -> " + quoted(viaBuffer)); - } + server.buffers().set("vector", value); + String viaBuffer = server.buffers().show("vector"); + String inBuffer = value.replaceAll("\n+$", ""); + if (!viaBuffer.equals(inBuffer)) { + lost.add("buffer " + quoted(value) + " -> " + quoted(viaBuffer)); + } + if (client != null) { client.send(List.of("set-buffer", "-b", "line", "--", value)); String viaLineBuffer = server.buffers().show("line"); if (!viaLineBuffer.equals(inBuffer)) { @@ -79,6 +97,40 @@ void aValueSurvivesTheArgumentVectorAndTheControlLine(Server server) { assertEquals(List.of(), lost); } + @Test + void aDelimiterWordDoesNotAddASecondCommand(Server server) { + server.buffers().set("user", "keep"); + assertFalse(server.cmd("set-option", "-g", "@delimiter", "changed", ";", "delete-buffer", "-bmissing") + .succeeded()); + assertTrue(server.globalOptions().get("@delimiter").isEmpty()); + assertEquals("keep", server.buffers().show("user")); + } + + @Test + @Tag("control") + void aDelimiterWordDoesNotAddAControlCommand(Server server) { + ControlSupport.require(server); + String name = server.windows().getFirst().name(); + try (ControlClient client = server.control(server.sessions().getFirst())) { + assertFalse(client.send( + "display-message", + "-p", + "literal", + ";", + "break-pane", + "-n", + "bad", + "-s", + server.panes().getFirst().id().value()) + .succeeded()); + assertEquals(name, server.windows().getFirst().name()); + assertEquals( + List.of("literal;"), + client.send("display-message", "-p", "literal;").lines()); + } + assertTrue(server.isAlive()); + } + private static List values(Random random, int count, String alphabet) { List values = new ArrayList<>(); int[] points = alphabet.codePoints().toArray(); 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 80955eebb..e88a93b97 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 @@ -13,8 +13,10 @@ import io.github.libtmux.exception.LibTmuxException; import io.github.libtmux.exception.TargetGoneException; import io.github.libtmux.junit5.TmuxExtension; +import io.github.libtmux.transport.ProcessTransport; import java.time.Duration; import java.util.List; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -70,7 +72,9 @@ void aFalseConditionWithNoOtherCommandDoesNothing(Server server) { } @Test + @Tag("control") void controlAttachesToTheCapturedServer(Server server) { + ControlSupport.require(server); Session session = server.sessions().get(0); try (ControlClient client = server.control(session)) { @@ -85,7 +89,9 @@ void controlAttachesToTheCapturedServer(Server server) { /** A new server on the same socket reuses {@code $0}. The old handle must not attach to it. */ @Test + @Tag("control") void controlRefusesAServerThatHasBeenReplaced(Server server) { + ControlSupport.require(server); Session session = server.sessions().get(0); ServerConfig config = server.config(); server.killServer(); @@ -102,7 +108,9 @@ void controlRefusesAServerThatHasBeenReplaced(Server server) { * with the old one, or its next command would be answered by the replacement. */ @Test + @Tag("control") void anAttachedClientEndsWhenItsServerIsReplaced(Server server) { + ControlSupport.require(server); Session session = server.sessions().get(0); String id = session.id().value(); ControlClient client = server.control(session); @@ -128,9 +136,9 @@ void anAttachedClientEndsWhenItsServerIsReplaced(Server server) { * until every client has gone, so a server started straight afterwards could reach the old one. */ @Test - void killingTheServerWaitsForItsDaemonToExit(Server server) { + void killingTheServerWaitsForItsDaemonToExit(Server server) throws Exception { long pid = Long.parseLong(server.expand("#{pid}")); - ControlClient client = server.control(server.sessions().get(0)); + NativeClient client = NativeClient.attach(server.sessions().get(0)); try { server.killServer(); @@ -142,18 +150,18 @@ void killingTheServerWaitsForItsDaemonToExit(Server server) { } } - /** The check runs after the process is up, so a lie about the pid has to detach again. */ + /** A captured pid mismatch is refused before control acquisition. */ @Test + @Tag("control") void controlDetachesWhenTheIncarnationDoesNotMatch(Server server) { + ControlSupport.require(server); Session session = server.sessions().get(0); assertThrows( TargetGoneException.class, () -> ControlClient.attach(server.config(), session.id(), 1, "0.0", Duration.ofSeconds(5))); - assertTrue( - Await.until(() -> noClients(server)), - "the control client stayed attached after the incarnation check failed"); + assertTrue(Await.until(() -> noClients(server)), "the refused identity left an attachment"); } /** @@ -161,22 +169,26 @@ void controlDetachesWhenTheIncarnationDoesNotMatch(Server server) { * restarted on its old pid shows a capture from before the restart. */ @Test - void controlDetachesFromAServerStartedAtAnotherTime(Server server) { + @Tag("control") + void controlRefusesAServerStartedAtAnotherTime(Server server) { + ControlSupport.require(server); Session session = server.sessions().get(0); var captured = server.snapshot(); String version = server.cmd("display-message", "-p", "#{version}").stdout().get(0); - assertThrows( - TargetGoneException.class, - () -> ControlClient.attach( - command -> new ProcessBuilder(command).start(), - server.config(), - session.id(), - captured.serverPid().orElseThrow(), - java.util.OptionalLong.of(captured.serverStartTime().orElseThrow() - 1), - version, - Duration.ofSeconds(5))); + try (ProcessTransport transport = new ProcessTransport()) { + assertThrows( + TargetGoneException.class, + () -> ControlClient.attach( + transport.controlCarrier().orElseThrow(), + server.config(), + session.id(), + captured.serverPid().orElseThrow(), + java.util.OptionalLong.of(captured.serverStartTime().orElseThrow() - 1), + version, + Duration.ofSeconds(5))); + } assertTrue( Await.until(() -> noClients(server)), @@ -220,20 +232,19 @@ void theMessageLogIsReadableDetachedFromThirtySixOnwards(Server server) { } } - /** The reason it is not gated: attach a client and the older releases answer too. */ @Test - void anOlderReleaseAnswersOnceAClientIsAttached(Server server) throws Exception { - if (server.version().atLeast(MESSAGES_WITHOUT_CLIENT_SINCE)) { - return; // nothing to prove; it already answers without one - } - Session session = server.sessions().get(0); - - try (ControlClient attached = server.control(session)) { - assertTrue(attached.send("display-message", "-p", "ready").succeeded()); - assertTrue(Await.until(() -> !server.clients().isEmpty()), "no client ever attached"); - - assertTrue(!server.messageLog().lines().isEmpty(), "with a client attached the log is readable after all"); + void theMessageLogIsReadableWithAnOrdinaryClientAttached(Server server) throws Exception { + try (NativeClient attached = NativeClient.attach(server.sessions().getFirst())) { + assertEquals( + List.of("0"), + server.cmd("list-clients", "-F", "#{client_control_mode}").stdout()); + attached.run("display-message", "ordinary-client-log-marker"); + assertTrue( + server.messageLog().lines().stream().anyMatch(line -> line.contains("ordinary-client-log-marker")), + "an ordinary client's message must remain readable"); } + assertTrue(Await.until(() -> noClients(server)), "closing the PTY left an attached client"); + assertTrue(server.isAlive(), "closing a client must preserve its server"); } // ------------------------------------------------------------------------------ prompt history diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ServerMirrorIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ServerMirrorIntegrationTest.java index 0f460f0db..ab08fe5fa 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/ServerMirrorIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/ServerMirrorIntegrationTest.java @@ -23,13 +23,21 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Predicate; import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; /** A mirror follows what tmux announces, and says so when it can no longer follow. */ +@Tag("control") @ExtendWith(TmuxExtension.class) final class ServerMirrorIntegrationTest { + @BeforeEach + void requireControl(Server server) { + ControlSupport.require(server); + } + private static final Duration PATIENCE = Duration.ofSeconds(10); @Test 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 e46ac6f25..09d2f7796 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 @@ -8,7 +8,6 @@ import io.github.libtmux.Server; import io.github.libtmux.TmuxFormats; import io.github.libtmux.TmuxVersion; -import io.github.libtmux.control.ControlClient; import io.github.libtmux.exception.LibTmuxException; import io.github.libtmux.exception.UnsupportedFeatureException; import io.github.libtmux.junit5.TmuxExtension; @@ -97,18 +96,12 @@ void aFormatInAnInterpolatedValueRunsUnlessItIsMadeLiteral(Server server, @TempD Path literal = directory.resolve("literal"); Path probe = directory.resolve("probe"); - // A control for #() dispatch itself, isolated from run-shell's own argument expansion: this - // tmux was observed on macOS never running the job at all, which no caller can work around. - server.expand("#(touch " + probe + ")"); - Assumptions.assumeTrue(Await.until(() -> Files.exists(probe)), "this tmux never dispatches a #() job"); - - // tmux cancels a client's asynchronous format jobs when that client disconnects. - // Keep the client alive through observation; a short-lived command races job cleanup. - try (ControlClient client = server.control(server.sessions().get(0))) { - assertTrue(client.send("run-shell", TmuxFormats.literal("echo '#(touch " + literal + ")' > /dev/null")) - .succeeded()); - assertTrue(client.send("run-shell", "echo '#(touch " + expanded + ")' > /dev/null") - .succeeded()); + // Keep the ordinary client alive while its asynchronous format jobs are observed. + try (NativeClient client = NativeClient.attach(server.sessions().getFirst())) { + client.run("display-message", "-p", "#(touch " + probe + ")"); + Assumptions.assumeTrue(Await.until(() -> Files.exists(probe)), "this tmux never dispatches a #() job"); + client.run("run-shell", TmuxFormats.literal("echo '#(touch " + literal + ")' > /dev/null")); + client.run("run-shell", "echo '#(touch " + expanded + ")' > /dev/null"); assertTrue(Await.until(() -> Files.exists(expanded)), "tmux expands a format inside shell quotes"); assertFalse(Files.exists(literal), "a literalized value must reach the shell as text"); diff --git a/integration-tests/src/test/java/io/github/libtmux/it/VersionCheckLaneIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/VersionCheckLaneIntegrationTest.java index fe69d6a66..05fedf046 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/VersionCheckLaneIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/VersionCheckLaneIntegrationTest.java @@ -229,7 +229,13 @@ void aKnownDaemonReplacedByAnOlderOneIsRefusedWithoutAnEffect() throws Exception server.control(known.getFirst(), Duration.ofSeconds(8)); } })); - assertTrue(refused.belowFloor(), path + ": " + refused.getMessage()); + if (path.equals("server.control")) { + // The cached version still decides, and it is a control refusal, not a floor one. + String reason = String.valueOf(refused.getMessage()); + assertTrue(reason.contains("requires tmux 3.7"), reason); + } else { + assertTrue(refused.belowFloor(), path + ": " + refused.getMessage()); + } assertEquals(List.of("kept"), raw(daemon, socket, "show-options", "-gqv", "@sentinel"), path); assertEquals(List.of("sentinel"), raw(daemon, socket, "list-sessions", "-F", "#{session_name}"), path); } finally { diff --git a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/ControlObservationSuite.scala b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/ControlObservationSuite.scala index a9a3eb19d..5ac927887 100644 --- a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/ControlObservationSuite.scala +++ b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/ControlObservationSuite.scala @@ -94,6 +94,10 @@ final class ControlObservationSuite extends FunSuite { test("registered output retains pane identity across forced marker chunks") { OwnedTmux.use { fixture => + assume( + fixture.supportsControl, + "managed control requires tmux 3.7+" + ) val pane = fixture.server.panes().get(0) val workersBefore = Thread.getAllStackTraces .keySet() @@ -162,6 +166,10 @@ final class ControlObservationSuite extends FunSuite { test("a parked reader cancels and releases the single consumer slot") { OwnedTmux.use { fixture => + assume( + fixture.supportsControl, + "managed control requires tmux 3.7+" + ) attach(fixture) .use { control => control.output(4).use { observation => @@ -196,6 +204,10 @@ final class ControlObservationSuite extends FunSuite { test("attachment is lazy and failed acquisition reclaims its client") { OwnedTmux.use { fixture => + assume( + fixture.supportsControl, + "managed control requires tmux 3.7+" + ) val before = fixture.server.clients().size() val acquisition = Control .attachUnfenced[IO]( @@ -226,6 +238,10 @@ final class ControlObservationSuite extends FunSuite { test("typed notifications report overflow and reconcile current state") { OwnedTmux.use { fixture => + assume( + fixture.supportsControl, + "managed control requires tmux 3.7+" + ) val window = fixture.server.windows().get(0) window.options().set("automatic-rename", "off") attach(fixture) @@ -280,6 +296,10 @@ final class ControlObservationSuite extends FunSuite { "scope close ends a parked stream and server loss fails it with the client's cause" ) { OwnedTmux.use { fixture => + assume( + fixture.supportsControl, + "managed control requires tmux 3.7+" + ) val result = attach(fixture).allocated.flatMap { case (control, close) => control.output(4).allocated.flatMap { case (observation, unsubscribe) => val awaited = parked(observation.underlying) @@ -340,6 +360,10 @@ final class ControlObservationSuite extends FunSuite { "acknowledgement retains raw reply without implying deferred completion" ) { OwnedTmux.use { fixture => + assume( + fixture.supportsControl, + "managed control requires tmux 3.7+" + ) val marker = fixture.directory.resolve("ack-completed") val prefix = fixture.config .endpointCommand() @@ -388,6 +412,10 @@ final class ControlObservationSuite extends FunSuite { "cancelling a queued or a dispatched call ends only that call" ) { OwnedTmux.use { fixture => + assume( + fixture.supportsControl, + "managed control requires tmux 3.7+" + ) attach(fixture) .use { control => for { diff --git a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/JavaBoundarySuite.scala b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/JavaBoundarySuite.scala index 6ad2df1ba..2cb40f13e 100644 --- a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/JavaBoundarySuite.scala +++ b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/JavaBoundarySuite.scala @@ -21,10 +21,12 @@ final class JavaBoundarySuite extends FunSuite { () } - private def control(fixture: OwnedTmux): ControlClient = + private def control(fixture: OwnedTmux): ControlClient = { + assume(fixture.supportsControl, "managed control requires tmux 3.7+") fixture.own( fixture.server.control(fixture.server.sessions().get(0), deadline) ) + } test("pane refresh follows physical identity across linked occurrences") { OwnedTmux.use { fixture => diff --git a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/LiveStateSuite.scala b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/LiveStateSuite.scala index cc7f99674..5d785ea37 100644 --- a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/LiveStateSuite.scala +++ b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/LiveStateSuite.scala @@ -23,6 +23,10 @@ final class LiveStateSuite extends FunSuite { "LiveView observes a real window creation as a newer, materially different snapshot" ) { OwnedTmux.use { fixture => + assume( + fixture.supportsControl, + "managed control requires tmux 3.7+" + ) Using.resource(Server.open(fixture.config)) { server => val session = server.newSession("live-direct") Using.resource(LiveView.attach(session)) { live => @@ -62,6 +66,10 @@ final class LiveStateSuite extends FunSuite { "LiveView snapshots fail with the cause once the anchor session is gone" ) { OwnedTmux.use { fixture => + assume( + fixture.supportsControl, + "managed control requires tmux 3.7+" + ) Using.resource(Server.open(fixture.config)) { server => server.newSession("live-keep") val anchor = server.newSession("live-anchor") diff --git a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/ObservationStressSuite.scala b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/ObservationStressSuite.scala index a3afb622b..24acf004a 100644 --- a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/ObservationStressSuite.scala +++ b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/ObservationStressSuite.scala @@ -35,6 +35,10 @@ final class ObservationStressSuite extends FunSuite { "direct style: Observation's gap accounting is exact, and the marker survives overflow" ) { OwnedTmux.use { fixture => + assume( + fixture.supportsControl, + "managed control requires tmux 3.7+" + ) Using.resource(Server.open(fixture.config)) { server => val session = server.newSession("stress-direct") val pane = session.windows.head.panes.head diff --git a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/QueryInteropSuite.scala b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/QueryInteropSuite.scala index 52747216d..25c461835 100644 --- a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/QueryInteropSuite.scala +++ b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/QueryInteropSuite.scala @@ -72,7 +72,8 @@ final class QueryInteropSuite extends FunSuite { .succeeded() ) } - fixture.own(ControlClient.attachUnfenced(fixture.config, first.id())) + if (fixture.supportsControl) + fixture.own(ControlClient.attachUnfenced(fixture.config, first.id())) val sessions = server.sessions() val windows = sessions.flatMap(_.windows) val panes = windows.flatMap(_.panes) @@ -141,7 +142,7 @@ final class QueryInteropSuite extends FunSuite { ), sessions.filter(_.windows.exists(_.info.context.index().value() == 4)) ) - assert(clients.nonEmpty) + assertEquals(clients.nonEmpty, fixture.supportsControl) assertEquals( clients.matching( Expr(Client_.session().is(Session_.name().is("fixture"))) diff --git a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/cats/CatsLiveServerSuite.scala b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/cats/CatsLiveServerSuite.scala index ede8c949b..64bdcfebc 100644 --- a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/cats/CatsLiveServerSuite.scala +++ b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/cats/CatsLiveServerSuite.scala @@ -38,16 +38,24 @@ final class CatsLiveServerSuite extends FunSuite { } } yield result } - val (initial, observed, failure) = program.unsafeRunSync() - assert( - observed.epoch() > initial.epoch(), - s"expected a newer epoch, got ${observed.epoch()} after ${initial.epoch()}" - ) - assertEquals( - observed.snapshot().windows().size(), - initial.snapshot().windows().size() + 1 - ) - assertEquals(failure, None) + // A live view is managed control: below tmux 3.7 it is refused before attaching. + if (!fixture.supportsControl) { + intercept[io.github.libtmux.exception.UnsupportedFeatureException]( + program.unsafeRunSync() + ) + assert(fixture.server.clients().isEmpty()) + } else { + val (initial, observed, failure) = program.unsafeRunSync() + assert( + observed.epoch() > initial.epoch(), + s"expected a newer epoch, got ${observed.epoch()} after ${initial.epoch()}" + ) + assertEquals( + observed.snapshot().windows().size(), + initial.snapshot().windows().size() + 1 + ) + assertEquals(failure, None) + } } } } diff --git a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/cats/CatsObservationStressSuite.scala b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/cats/CatsObservationStressSuite.scala index 71308c560..731e2d64c 100644 --- a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/cats/CatsObservationStressSuite.scala +++ b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/cats/CatsObservationStressSuite.scala @@ -31,6 +31,10 @@ final class CatsObservationStressSuite extends FunSuite { "Cats: the poll/onReady fs2 bridge's gap accounting is exact, the marker survives overflow" ) { OwnedTmux.use { fixture => + assume( + fixture.supportsControl, + "managed control requires tmux 3.7+" + ) val events = new AtomicLong(0) val gaps = new AtomicLong(0) val gapMissed = new AtomicLong(0) diff --git a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/fixture/OwnedTmuxSuite.scala b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/fixture/OwnedTmuxSuite.scala index 3a842b320..4e2f9fc7d 100644 --- a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/fixture/OwnedTmuxSuite.scala +++ b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/fixture/OwnedTmuxSuite.scala @@ -11,6 +11,10 @@ import scala.util.Using final class OwnedTmuxSuite extends FunSuite { test("owned cleanup removes the daemon, socket, children and workers") { OwnedTmux.use { fixture => + assume( + fixture.supportsControl, + "managed control requires tmux 3.7+" + ) assert(fixture.server.isAlive()) val daemon = fixture.serverProcess val control = fixture.own( diff --git a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/ox/OxFlowsSuite.scala b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/ox/OxFlowsSuite.scala index 553d58d47..46cdf23c7 100644 --- a/integration-tests/src/test/scala/io/github/libtmux/scaladsl/ox/OxFlowsSuite.scala +++ b/integration-tests/src/test/scala/io/github/libtmux/scaladsl/ox/OxFlowsSuite.scala @@ -27,6 +27,10 @@ final class OxFlowsSuite extends FunSuite { "a subscription flow delivers what arrived before it was collected, then stops at take" ) { OwnedTmux.use { fixture => + assume( + fixture.supportsControl, + "managed control requires tmux 3.7+" + ) Using.resource(Server.open(fixture.config)) { server => val session = server.newSession("ox-events") Using.resource(server.control(session)) { control => @@ -47,6 +51,10 @@ final class OxFlowsSuite extends FunSuite { "a second collection of one subscription is refused while the first holds it" ) { OwnedTmux.use { fixture => + assume( + fixture.supportsControl, + "managed control requires tmux 3.7+" + ) Using.resource(Server.open(fixture.config)) { server => val session = server.newSession("ox-claim") Using.resource(server.control(session)) { control => @@ -81,6 +89,10 @@ final class OxFlowsSuite extends FunSuite { "a live-view flow starts from the current view, then sees a change made after it began" ) { OwnedTmux.use { fixture => + assume( + fixture.supportsControl, + "managed control requires tmux 3.7+" + ) Using.resource(Server.open(fixture.config)) { server => val session = server.newSession("ox-live") Using.resource(LiveView.attach(session)) { live => diff --git a/integration-tests/src/testFixtures/scala/io/github/libtmux/scaladsl/fixture/OwnedTmux.scala b/integration-tests/src/testFixtures/scala/io/github/libtmux/scaladsl/fixture/OwnedTmux.scala index bee64876c..30dd0cc51 100644 --- a/integration-tests/src/testFixtures/scala/io/github/libtmux/scaladsl/fixture/OwnedTmux.scala +++ b/integration-tests/src/testFixtures/scala/io/github/libtmux/scaladsl/fixture/OwnedTmux.scala @@ -1,6 +1,12 @@ package io.github.libtmux.scaladsl.fixture -import io.github.libtmux.{Server, ServerConfig, ServerEndpoint, SessionSpec} +import io.github.libtmux.{ + Server, + ServerConfig, + ServerEndpoint, + SessionSpec, + TmuxVersion +} import io.github.libtmux.junit5.NamedServerFixture import java.nio.file.{Files, LinkOption, Path} import java.time.Duration @@ -23,6 +29,10 @@ private[scaladsl] final class OwnedTmux private ( private var process: Option[ProcessHandle] = None private var closed = false + def supportsControl: Boolean = { + io.github.libtmux.internal.ControlAcquisition.supports(server.version()) + } + def serverProcess: ProcessHandle = process.getOrElse( throw new IllegalStateException("fixture has not started") diff --git a/libtmux-junit5/src/main/java/io/github/libtmux/junit5/FakeTmux.java b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/FakeTmux.java index d13a0a92d..b101dd60f 100644 --- a/libtmux-junit5/src/main/java/io/github/libtmux/junit5/FakeTmux.java +++ b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/FakeTmux.java @@ -14,13 +14,16 @@ import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.function.Function; import org.jspecify.annotations.Nullable; @@ -57,6 +60,9 @@ public final class FakeTmux implements TmuxTransport { /** The release it claims to be, one every supported feature is available on. */ private static final String VERSION = "3.7c"; + // Pins retain model identities; this fake has no host socket. + private final Map controlPins = new HashMap<>(); + private static final String LAYOUT = "c3a4,80x24,0,0,1"; /** @@ -251,16 +257,54 @@ public synchronized CommandResult execute(CommandRequest request) { */ @Override public Optional controlCarrier() { - return Optional.of(command -> { - if (command.size() >= 3 && isVersionProbe(command.subList(command.size() - 3, command.size()))) { - return new VersionProbe(refusal(command.getLast())); + return Optional.of(new ControlCarrier() { + @Override + public Process start(List command) throws IOException { + synchronized (FakeTmux.this) { + int selector = command.indexOf("-S"); + if (selector >= 0 + && !Objects.equals(controlPins.get(command.get(selector + 1)), pid + ":" + started)) { + throw new IOException("the fake pinned incarnation has ended"); + } + if (command.size() >= 3 && isVersionProbe(command.subList(command.size() - 3, command.size()))) { + return VersionProbe.refusing(refusal(command.getLast())); + } + if (command.size() >= 3 + && command.subList(command.size() - 3, command.size() - 1) + .equals(List.of("display-message", "-p")) + && command.getLast().equals("#{version}\t#{pid}\t#{start_time}\t#{socket_path}")) { + return VersionProbe.reporting( + VERSION + "\t" + pid + "\t" + started + "\t/tmp/libtmux-java-fake/" + pid); + } + if (command.size() >= 4 + && command.subList(command.size() - 4, command.size() - 1) + .equals(List.of("-C", "attach-session", "-t"))) return attach(); + throw new IOException("the fake carrier supports identity probes and control attachment"); + } } - if (command.size() >= 4 - && command.subList(command.size() - 4, command.size() - 1) - .equals(List.of("-C", "attach-session", "-t"))) { - return attach(); + + @Override + public PinnedSocket pinSocket(String socketPath, Duration timeout) throws IOException { + synchronized (FakeTmux.this) { + if (!socketPath.equals("/tmp/libtmux-java-fake/" + pid)) + throw new IOException("the fake socket incarnation has ended"); + String alias = socketPath + "/control-" + UUID.randomUUID(); + controlPins.put(alias, pid + ":" + started); + return new PinnedSocket() { + @Override + public String socketPath() { + return alias; + } + + @Override + public void close() { + synchronized (FakeTmux.this) { + controlPins.remove(alias); + } + } + }; + } } - throw new IOException("the fake carrier supports version probes and control attachment"); }); } @@ -280,13 +324,23 @@ private static String refusal(String format) { } private static final class VersionProbe extends Process { + private final InputStream output; private final InputStream error; + private final int exit; + + private VersionProbe(int exit, String stdout, String stderr) { + this.exit = exit; + output = new ByteArrayInputStream((stdout.isEmpty() ? "" : stdout + "\n").getBytes(StandardCharsets.UTF_8)); + error = new ByteArrayInputStream((stderr.isEmpty() ? "" : stderr + "\n").getBytes(StandardCharsets.UTF_8)); + } - VersionProbe(String stderr) { - this.error = new ByteArrayInputStream((stderr + "\n").getBytes(StandardCharsets.UTF_8)); + static VersionProbe reporting(String report) { + return new VersionProbe(0, report, ""); } - private final InputStream output = InputStream.nullInputStream(); + static VersionProbe refusing(String stderr) { + return new VersionProbe(1, "", stderr); + } @Override public OutputStream getOutputStream() { @@ -305,12 +359,12 @@ public InputStream getErrorStream() { @Override public int waitFor() { - return 1; + return exit; } @Override public int exitValue() { - return 1; + return exit; } @Override diff --git a/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/ControlSupport.kt b/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/ControlSupport.kt new file mode 100644 index 000000000..0d255f8d9 --- /dev/null +++ b/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/ControlSupport.kt @@ -0,0 +1,13 @@ +package io.github.libtmux.kotlin + +import io.github.libtmux.Server as JavaServer +import io.github.libtmux.internal.ControlAcquisition +import org.junit.jupiter.api.Assumptions.assumeTrue + +/** + * Managed control needs tmux 3.7 or newer. A test that attaches a control client skips below it; + * the integration suite asserts the refusal there. + */ +internal fun requireControl(server: JavaServer) { + assumeTrue(ControlAcquisition.supports(server.version()), "managed control needs tmux 3.7 or newer") +} diff --git a/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/FakeControlClients.kt b/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/FakeControlClients.kt index d7fd6d651..cc820f36e 100644 --- a/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/FakeControlClients.kt +++ b/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/FakeControlClients.kt @@ -3,6 +3,9 @@ package io.github.libtmux.kotlin import io.github.libtmux.ServerConfig import io.github.libtmux.SessionId import io.github.libtmux.control.ControlClient +import io.github.libtmux.transport.ControlCarrier +import java.time.Duration +import java.util.OptionalLong import java.nio.file.Files import java.nio.file.Path import java.nio.file.attribute.PosixFilePermissions @@ -32,7 +35,16 @@ internal object FakeControlClients { ) Files.setPosixFilePermissions(fake, PosixFilePermissions.fromString("rwx------")) val config = ServerConfig.builder().binary(fake.toString()).build() - return ControlClient.attachUnfenced(config, SessionId("\$0")) + val carrier = object : ControlCarrier { + override fun start(command: List): Process = ProcessBuilder(command).start() + + override fun pinSocket(socketPath: String, remaining: Duration): ControlCarrier.PinnedSocket = + object : ControlCarrier.PinnedSocket { + override fun socketPath(): String = "/fake-control-pin" + override fun close() = Unit + } + } + return ControlClient.attach(carrier, config, SessionId("\$0"), 4242, OptionalLong.of(10), "3.7c", Duration.ofSeconds(30)) } /** @@ -42,15 +54,33 @@ internal object FakeControlClients { * this client's command, then the marker's block. */ private val PRELUDE = - "for argument in \"\$@\"; do case \"\$argument\" in libtmux-version-*'#{version}') " + - "printf 'unknown command: %s3.7c\\n' \"\${argument%'#{version}'}\" >&2; exit 1;; esac; done\n" + - "printf '%%begin 100 1 0\\n%%end 100 1 0\\n'\n" + - "read_request() { IFS= read -r request && IFS= read -r marker; }\n" + - "answer() {\n" + - " printf '%%begin 1 1 1\\n'\n" + - " for line in \"\$@\"; do printf '%s\\n' \"\$line\"; done\n" + - " printf '%%end 1 1 1\\n'\n" + - " token=\${marker##*\"' '\"}\n" + - " printf '%%begin 1 2 1\\n%s\\n%%end 1 2 1\\n' \"\${token%\"'\"}\"\n" + - "}\n" + """ + for argument in "${'$'}@"; do + case "${'$'}argument" in + libtmux-version-*'#{version}') + printf 'unknown command: %s3.7c\n' "${'$'}{argument%'#{version}'}" >&2 + exit 1;; + esac + if [ "${'$'}argument" = '#{version} #{pid} #{start_time} #{socket_path}' ]; then + printf '3.7c\t4242\t10\t/fake-control\n'; exit + fi + done + printf '%%begin 100 1 0\n%%end 100 1 0\n' + read_request() { + while IFS= read -r request && IFS= read -r marker; do + case "${'$'}request" in + *'#{start_time} #{pid} #{version}'*) answer '10 4242 3.7c';; + *) return 0;; + esac + done + return 1 + } + answer() { + printf '%%begin 1 1 1\n' + for line in "${'$'}@"; do printf '%s\n' "${'$'}line"; done + printf '%%end 1 1 1\n' + token=${'$'}{marker##*"' '"} + printf '%%begin 1 2 1\n%s\n%%end 1 2 1\n' "${'$'}{token%"'"}" + } + """.trimIndent() + "\n" } diff --git a/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/LiveStateTest.kt b/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/LiveStateTest.kt index 945ab3b35..642c88bc8 100644 --- a/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/LiveStateTest.kt +++ b/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/LiveStateTest.kt @@ -9,6 +9,8 @@ import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import io.github.libtmux.Server as JavaServer @@ -19,9 +21,13 @@ import io.github.libtmux.Server as JavaServer * `coroutineScope { liveState(...) }` never returns — structured concurrency waits for every child, * the pump included, and nothing inside it ever asks the pump to stop. */ +@Tag("control") @ExtendWith(TmuxExtension::class) class LiveStateTest { + @BeforeEach + fun control(javaServer: JavaServer) = requireControl(javaServer) + @Test fun `the initial view is already populated, no empty window`(javaServer: JavaServer) = runBlocking { val server = Server.open(javaServer.config()) diff --git a/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/ReadmeExamplesTest.kt b/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/ReadmeExamplesTest.kt index fad6c748f..5f293547e 100644 --- a/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/ReadmeExamplesTest.kt +++ b/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/ReadmeExamplesTest.kt @@ -20,6 +20,7 @@ import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import io.github.libtmux.Server as JavaServer @@ -130,7 +131,9 @@ class ReadmeExamplesTest { } @Test + @Tag("control") fun `a subscription as a cold Flow`(javaServer: JavaServer) = runBlocking { + requireControl(javaServer) withServer(javaServer.config()) { server -> val session = server.newSession("flow-demo") withControl(server, session) { control -> @@ -147,7 +150,9 @@ class ReadmeExamplesTest { } @Test + @Tag("control") fun `the live server as a StateFlow`(javaServer: JavaServer) = runBlocking { + requireControl(javaServer) withServer(javaServer.config()) { server -> val session = server.newSession("live-demo") server.withLiveState(session) { live -> diff --git a/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/WaitsTest.kt b/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/WaitsTest.kt index 2fecd73ea..dbe75c59c 100644 --- a/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/WaitsTest.kt +++ b/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/WaitsTest.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import io.github.libtmux.Server as JavaServer @@ -130,7 +131,9 @@ class WaitsTest { } @Test + @Tag("control") fun `control attaches a working client against real tmux`(javaServer: JavaServer) = runBlocking { + requireControl(javaServer) val server = Server.open(javaServer.config()) val session = server.sessions().first() @@ -155,15 +158,17 @@ class WaitsTest { * wait is stalled by construction rather than by timing. */ @Test + @Tag("control") fun `cancelling control ends promptly and kills the stalled process`() { val stall = StalledControlFixture.start() try { + requireControl(stall.javaServer) runBlocking { val server = Server.open(stall.javaServer.config()) val session = Session(stall.javaSession, server) val attaching = async(Dispatchers.IO) { server.control(session, 30.seconds) } - val pid = awaitControlClientPid(stall.socket, 5.seconds) + val pid = awaitControlClientPid(stall.controlCommand, 5.seconds) assertNotNull(pid, "the wrapped control client never started") assertFalse(attaching.isCompleted, "attach finished before it could be caught stalled") @@ -196,7 +201,7 @@ private class StalledControlFixture private constructor( val javaServer: JavaServer, val javaSession: JavaSession, ) { - val socket: String = root.resolve("s").toString() + val controlCommand: String = root.resolve("wrap.sh").toString() fun close() { try { @@ -255,21 +260,21 @@ private fun waiterPresent(socket: String): Boolean = } // A direct child of this JVM, not a system-wide scan of every process on the machine. -private fun controlClientPid(socket: String): Long? = +private fun controlClientPid(command: String): Long? = ProcessHandle.current() .children() .filter { process -> val argv = process.info().arguments().orElse(emptyArray()) - "-C" in argv && "attach-session" in argv && socket in argv + "-C" in argv && "attach-session" in argv && command in argv } .findFirst() .map(ProcessHandle::pid) .orElse(null) -private suspend fun awaitControlClientPid(socket: String, timeout: Duration): Long? { +private suspend fun awaitControlClientPid(command: String, timeout: Duration): Long? { val end = System.nanoTime() + timeout.inWholeNanoseconds while (System.nanoTime() < end) { - val pid = controlClientPid(socket) + val pid = controlClientPid(command) if (pid != null) { return pid } diff --git a/libtmux-scala-cats/src/test/scala/io/github/libtmux/scaladsl/cats/ObservationSuite.scala b/libtmux-scala-cats/src/test/scala/io/github/libtmux/scaladsl/cats/ObservationSuite.scala index b9e719609..520cc860e 100644 --- a/libtmux-scala-cats/src/test/scala/io/github/libtmux/scaladsl/cats/ObservationSuite.scala +++ b/libtmux-scala-cats/src/test/scala/io/github/libtmux/scaladsl/cats/ObservationSuite.scala @@ -1,11 +1,14 @@ package io.github.libtmux.scaladsl.cats import io.github.libtmux.exception.ControlEndedException -import _root_.cats.effect.{Deferred, IO} +import _root_.cats.effect.{Deferred, IO, Resource} import _root_.cats.effect.unsafe.implicits.global import _root_.cats.syntax.all._ import io.github.libtmux.ServerConfig import io.github.libtmux.SessionId +import io.github.libtmux.transport.ControlCarrier +import java.time.Duration +import java.util.OptionalLong import io.github.libtmux.control.{ControlClient, Delivery, PaneOutput} import java.nio.file.Files import java.nio.file.attribute.PosixFilePermissions @@ -38,8 +41,8 @@ final class ObservationSuite extends FunSuite { PosixFilePermissions.fromString("rwx------") ) val config = ServerConfig.builder().binary(fake.toString).build() - val program = Control - .attachUnfenced[IO](config, new SessionId("$0")) + val program = ObservationSuite + .attached(config) .use(control => control.output(1).use(_.stream.compile.drain)) val ran = try program.timeout(3.seconds).attempt.unsafeRunSync() @@ -75,7 +78,7 @@ final class ObservationSuite extends FunSuite { PosixFilePermissions.fromString("rwx------") ) val config = ServerConfig.builder().binary(fake.toString).build() - val client = ControlClient.attachUnfenced(config, new SessionId("$0")) + val client = ObservationSuite.client(config) val subscription = client.subscribeOutput(1) val program = Observation .resource[IO, PaneOutput](subscription, new AtomicBoolean(false)) @@ -143,7 +146,7 @@ final class ObservationSuite extends FunSuite { ) val config = ServerConfig.builder().binary(fake.toString).build() val program = - Control.attachUnfenced[IO](config, new SessionId("$0")).use { control => + ObservationSuite.attached(config).use { control => for { alive <- control.isAlive watched <- control.watch("cmd", "%1", "#{pane_current_command}") @@ -195,7 +198,7 @@ final class ObservationSuite extends FunSuite { ) val config = ServerConfig.builder().binary(fake.toString).build() val program = - Control.attachUnfenced[IO](config, new SessionId("$0")).use { control => + ObservationSuite.attached(config).use { control => for { _ <- IO.sleep(200.millis) text <- control.standardError @@ -217,6 +220,43 @@ final class ObservationSuite extends FunSuite { object ObservationSuite { + // Protocol fixtures qualify their model realm; native pinning is tested against real sockets. + def client(config: ServerConfig): ControlClient = { + val carrier = new ControlCarrier { + override def start(command: java.util.List[String]): Process = + new ProcessBuilder(command).start() + override def pinSocket( + socket: String, + budget: Duration + ): ControlCarrier.PinnedSocket = + new ControlCarrier.PinnedSocket { + override def socketPath(): String = "/fake-control-pin" + override def close(): Unit = () + } + } + ControlClient.attach( + carrier, + config, + new SessionId("$0"), + 4242L, + OptionalLong.of(10L), + "3.7c", + Duration.ofSeconds(30) + ) + } + + def attached(config: ServerConfig): Resource[IO, Control[IO]] = + for { + closed <- Resource.eval(IO(new AtomicBoolean(false))) + underlying <- Resource.make(IO.interruptible(client(config)))(client => + IO.interruptible(client.close()) + ) + execution <- Execution.resource[IO](4) + control <- Resource.make( + IO(new Control[IO](underlying, closed, execution)) + )(_.markClosed) + } yield control + /** What every fake starts with. tmux follows each request line with a marker * line, a `display-message -p` of a token, and a reply ends with the * marker's block: `read_request` reads a request and its marker, and @@ -224,9 +264,24 @@ object ObservationSuite { * marker's block. */ val prelude: String = - """|for argument in "$@"; do case "$argument" in libtmux-version-*'#{version}') - | printf 'unknown command: %s3.7c\n' "${argument%'#{version}'}" >&2; exit 1;; esac; done - |read_request() { IFS= read -r request && IFS= read -r marker; } + """|for argument in "$@"; do + | case "$argument" in + | libtmux-version-*'#{version}') + | printf 'unknown command: %s3.7c\n' "${argument%'#{version}'}" >&2; exit 1;; + | esac + | if [ "$argument" = '#{version} #{pid} #{start_time} #{socket_path}' ]; then + | printf '3.7c\t4242\t10\t/fake-control\n'; exit + | fi + |done + |read_request() { + | while IFS= read -r request && IFS= read -r marker; do + | case "$request" in + | *'#{start_time} #{pid} #{version}'*) answer '10 4242 3.7c';; + | *) return 0;; + | esac + | done + | return 1 + |} |answer() { | printf '%%begin 1 1 1\n' | for line in "$@"; do printf '%s\n' "$line"; done diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index 628fbdfd3..cb31ae827 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -951,6 +951,8 @@ public ControlClient control(Session session, Duration timeout) { } requireOpen(); ServerSnapshot captured = session.snapshot(); + // Before the identity read below, so an unsupported daemon is refused without running anything on it. + io.github.libtmux.internal.ControlAcquisition.requireSupported(version(captured)); long pid = captured.serverPid() .orElseThrow(() -> new IllegalStateException("a live handle has no server process identity")); CommandResult live = fence.guarded(captured, "display-message -p '#{pid} #{version}'", ""); 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 a26220133..6110a0dd1 100644 --- a/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java +++ b/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java @@ -3,6 +3,7 @@ import io.github.libtmux.PaneId; import io.github.libtmux.ServerConfig; import io.github.libtmux.SessionId; +import io.github.libtmux.TmuxVersion; import io.github.libtmux.batch.OperationOutcome; import io.github.libtmux.catalog.Kind; import io.github.libtmux.catalog.Operation; @@ -12,10 +13,12 @@ import io.github.libtmux.exception.LibTmuxException; import io.github.libtmux.exception.MalformedResponseException; import io.github.libtmux.exception.TargetGoneException; +import io.github.libtmux.internal.ControlAcquisition; import io.github.libtmux.internal.ErrorText; +import io.github.libtmux.internal.LocalSocketPin; import io.github.libtmux.internal.ProcessTree; +import io.github.libtmux.internal.TmuxFeatures; import io.github.libtmux.internal.Utf8; -import io.github.libtmux.internal.support.Versions; import io.github.libtmux.transport.ControlCarrier; import io.github.libtmux.transport.DispatchOutcome; import io.github.libtmux.transport.OperationObserver; @@ -71,13 +74,24 @@ public final class ControlClient implements AutoCloseable { private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); - private static final ControlCarrier LOCAL = command -> new ProcessBuilder(command).start(); + private static final ControlCarrier LOCAL = new ControlCarrier() { + @Override + public Process start(List command) throws IOException { + return new ProcessBuilder(command).start(); + } + + @Override + public PinnedSocket pinSocket(String socketPath, Duration timeout) throws IOException { + return LocalSocketPin.retain(socketPath); + } + }; /** The same seam as the process transport's, since this client starts its own process. */ private static final System.Logger LOG = System.getLogger(ControlClient.class.getName()); private static final long EXIT_MILLIS = 5_000; + private final ControlAcquisition acquisition; private final Process process; private final ProcessTree processTree; private final InputStream standardOutput; @@ -104,7 +118,8 @@ public final class ControlClient implements AutoCloseable { private int stderrLength; private boolean stderrTruncated; - private ControlClient(Process process, OperationObserver observer) { + private ControlClient(Process process, OperationObserver observer, ControlAcquisition acquisition) { + this.acquisition = acquisition; this.process = process; this.observer = observer; this.processTree = new ProcessTree(process); @@ -154,9 +169,7 @@ public static ControlClient attachUnfenced(ServerConfig config, SessionId sessio */ @Operation(Kind.LIFECYCLE) public static ControlClient attachUnfenced(ServerConfig config, SessionId session, Duration timeout) { - ControlClient client = connect(LOCAL, config, session, timeout); - client.finishAttach(session); - return client; + return connect(LOCAL, config, session, timeout, null); } /** @@ -194,74 +207,77 @@ public static ControlClient attach( if (serverPid < 1) { throw new IllegalArgumentException("serverPid is not positive: " + serverPid); } - ControlClient client = connect(carrier, config, session, timeout); - try { - client.confirmIncarnation(serverPid, serverStartTime, serverVersion); - } catch (RuntimeException failure) { - client.closeAfterFailure(failure); - throw failure; - } - client.finishAttach(session); - return client; + return connect(carrier, config, session, timeout, new Expected(serverPid, serverStartTime, serverVersion)); } + private record Expected(long pid, java.util.OptionalLong start, String version) {} + private static ControlClient connect( - ControlCarrier carrier, ServerConfig config, SessionId session, Duration timeout) { + ControlCarrier carrier, + ServerConfig config, + SessionId session, + Duration timeout, + @org.jspecify.annotations.Nullable Expected expected) { Objects.requireNonNull(carrier, "carrier"); Objects.requireNonNull(config, "config"); Objects.requireNonNull(session, "session"); 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())); - // The same guard CommandRequest applies to every other process this library starts. A - // control client's own commands travel as UTF-8 over its standard input and are unaffected, - // but this argv is encoded by the JVM like any other. - Utf8.requireEncodableArguments(command); - long started = System.nanoTime(); - Versions.requireCarrier(carrier, config.endpointCommand(), timeout); - Duration remaining = timeout.minusNanos(System.nanoTime() - started); - if (remaining.isZero() || remaining.isNegative()) { - throw new DispatchException.TimedOut( - "the tmux version check exceeded the attach deadline", DispatchOutcome.NOT_DISPATCHED, null); - } - Process process; - try { - process = carrier.start(command); - } catch (IOException e) { - throw new DispatchException.Failed("could not start a control client", DispatchOutcome.NOT_DISPATCHED, e); - } - ControlClient client = new ControlClient(process, config.observer()); - // 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. - ControlWriter.Request attached = client.writer.expectInitial(remaining); - client.reader.start(); - ControlReply reply; + if (timeout.isZero() || timeout.isNegative()) throw new IllegalArgumentException("timeout is not positive"); + ControlAcquisition acquisition = ControlAcquisition.acquire(carrier, config, timeout); try { - reply = client.writer.await(attached); - } catch (DispatchException e) { - client.closeAfterFailure(e); - throw e; - } - if (reply.outcome() != OperationOutcome.COMPLETE) { - LibTmuxException failure = new CommandRejectedException( - "the control client did not become ready" + ErrorText.suffix(reply.lines()), - "attach-session", - -1, - reply.lines()); - client.closeAfterFailure(failure); + if (expected != null) acquisition.verify(expected.pid(), expected.start(), expected.version()); + List command = new ArrayList<>(acquisition.command()); + command.addAll(List.of("-C", "attach-session", "-t", session.value())); + Utf8.requireEncodableArguments(command); + acquisition.remaining(false); + Process process; + try { + process = carrier.start(command); + } catch (IOException failure) { + throw new DispatchException.Failed( + "could not start a control client", DispatchOutcome.NOT_DISPATCHED, failure); + } + ControlClient client = new ControlClient(process, config.observer(), acquisition); + try { + ControlWriter.Request opening = client.writer.expectInitial(acquisition.remaining(true)); + client.reader.start(); + ControlReply reply = client.writer.await(opening); + if (reply.outcome() != OperationOutcome.COMPLETE) { + throw new CommandRejectedException( + "the control client did not become ready" + ErrorText.suffix(reply.lines()), + "attach-session", + -1, + reply.lines()); + } + client.writer.start(); + var identity = acquisition.identity(); + client.confirmIncarnation( + identity.pid(), + java.util.OptionalLong.of(identity.start()), + identity.version(), + acquisition.remaining(true)); + client.requestJsonLayouts(acquisition.remaining(true)); + client.finishAttach(session); + return client; + } catch (RuntimeException failure) { + client.closeAfterFailure(failure); + throw failure; + } + } catch (RuntimeException failure) { + try { + acquisition.close(); + } catch (RuntimeException cleanup) { + failure.addSuppressed(cleanup); + } throw failure; } - client.writer.start(); - return client; } /** Reads the live server and detaches, via the caller, when it is not the one that was named. */ - private void confirmIncarnation(long expectedPid, java.util.OptionalLong expectedStart, String expectedVersion) { + private void confirmIncarnation( + long expectedPid, java.util.OptionalLong expectedStart, String expectedVersion, Duration timeout) { // The start time leads: it is a number, and the version, compared as text, may hold a space. - ControlReply reply = send("display-message", "-p", "#{start_time} #{pid} #{version}"); + ControlReply reply = send(List.of("display-message", "-p", "#{start_time} #{pid} #{version}"), timeout); if (!reply.succeeded() || reply.lines().size() != 1) { throw new CommandRejectedException( "could not read the control client's server" + ErrorText.suffix(reply.lines()), @@ -287,7 +303,6 @@ private void confirmIncarnation(long expectedPid, java.util.OptionalLong expecte } private void finishAttach(SessionId session) { - requestJsonLayouts(); LOG.log(System.Logger.Level.DEBUG, "tmux control client attached to session {0}", session.value()); } @@ -302,8 +317,8 @@ private void finishAttach(SessionId session) { * new-layouts} completes with no error on every supported release, just with nothing to change * before 3.8, so this is sent unconditionally rather than gated on a version. */ - private void requestJsonLayouts() { - ControlReply reply = send("refresh-client", "-f", "new-layouts"); + private void requestJsonLayouts(Duration timeout) { + ControlReply reply = send(List.of("refresh-client", "-f", "new-layouts"), timeout); if (reply.outcome() != OperationOutcome.COMPLETE) { LibTmuxException failure = new CommandRejectedException( "could not request JSON layouts on attach" + ErrorText.suffix(reply.lines()), @@ -362,6 +377,8 @@ public ControlReply send(List argv, Duration timeout) { if (closed.get() || failed) { throw new IllegalStateException("control client is not usable"); } + TmuxFeatures.require( + TmuxVersion.parse(acquisition.identity().version()), TmuxFeatures.Intent.literal(List.of(argv))); long started = System.nanoTime(); try { ControlReply reply = writer.exchange(line(argv), timeout); @@ -498,32 +515,36 @@ public ControlReply unwatch(String name) { @Operation(Kind.LIFECYCLE) @Override public void close() { - boolean closeOwner = closed.compareAndSet(false, true); - if (closeOwner) { - processTree.captureDescendants(); - writer.close(); - closeSubscriptions(null); - } - boolean reclaimed = processTree.terminate(); - if (!closeOwner) { + try { + boolean closeOwner = closed.compareAndSet(false, true); + if (closeOwner) { + processTree.captureDescendants(); + writer.close(); + closeSubscriptions(null); + } + boolean reclaimed = processTree.terminate(); + if (!closeOwner) { + if (!reclaimed) { + throw new IllegalStateException("control process tree was not reclaimed"); + } + return; + } + AtomicBoolean interrupted = new AtomicBoolean(Thread.interrupted()); + 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(); + } if (!reclaimed) { throw new IllegalStateException("control process tree was not reclaimed"); } - return; - } - AtomicBoolean interrupted = new AtomicBoolean(Thread.interrupted()); - 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(); - } - if (!reclaimed) { - throw new IllegalStateException("control process tree was not reclaimed"); + } finally { + acquisition.close(); } } @@ -561,6 +582,12 @@ private void read() { } catch (IOException | ControlProtocol.LimitExceeded e) { readerFailure = e; } finally { + try { + acquisition.close(); + } catch (RuntimeException cleanup) { + if (readerFailure == null) readerFailure = cleanup; + else readerFailure.addSuppressed(cleanup); + } readerEnded = true; closeSubscriptions( readerFailure == null && closed.get() diff --git a/libtmux/src/main/java/io/github/libtmux/internal/ControlAcquisition.java b/libtmux/src/main/java/io/github/libtmux/internal/ControlAcquisition.java new file mode 100644 index 000000000..ab60c7f1d --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/internal/ControlAcquisition.java @@ -0,0 +1,184 @@ +package io.github.libtmux.internal; + +import io.github.libtmux.ServerConfig; +import io.github.libtmux.TmuxVersion; +import io.github.libtmux.exception.CommandRejectedException; +import io.github.libtmux.exception.DispatchException; +import io.github.libtmux.exception.MalformedResponseException; +import io.github.libtmux.exception.TargetGoneException; +import io.github.libtmux.exception.UnsupportedFeatureException; +import io.github.libtmux.internal.support.Versions; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.ControlCarrier; +import io.github.libtmux.transport.DispatchOutcome; +import io.github.libtmux.transport.ProcessTransport; +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.OptionalLong; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Qualifies a carrier-owned endpoint before the first control effect. */ +public final class ControlAcquisition implements AutoCloseable { + private static final String FORMAT = "#{version}\t#{pid}\t#{start_time}\t#{socket_path}"; + private static final TmuxVersion MINIMUM = new TmuxVersion(3, 7, ""); + private final ControlCarrier.PinnedSocket pin; + private final Identity identity; + private final List command; + private final Duration budget; + private final long began; + private final AtomicBoolean closed = new AtomicBoolean(); + + private ControlAcquisition( + ControlCarrier.PinnedSocket pin, Identity identity, List command, Duration budget, long began) { + this.pin = pin; + this.identity = identity; + this.command = command; + this.budget = budget; + this.began = began; + } + + public static ControlAcquisition acquire(ControlCarrier carrier, ServerConfig config, Duration budget) { + long began = System.nanoTime(); + // The hook-free probe decides first: a daemon below the floor must not run a user's + // after-display-message hook for the identity read that follows. + TmuxVersion version = Versions.requireCarrier(carrier, config.endpointCommand(), budget); + requireSupported(version); + Identity identity = read(carrier, config.endpointCommand(), remaining(budget, began, false)); + if (!identity.version().equals(version.toString())) { + throw new TargetGoneException("the tmux endpoint changed during control acquisition"); + } + ControlCarrier.PinnedSocket pin; + try { + pin = carrier.pinSocket(identity.socket(), remaining(budget, began, false)); + } catch (UnsupportedOperationException failure) { + throw new UnsupportedFeatureException( + "control acquisition requires endpoint pinning in the carrier's realm"); + } catch (IOException failure) { + throw new DispatchException.Failed( + "cannot retain the control socket", DispatchOutcome.NOT_DISPATCHED, failure); + } + try { + List command = new ArrayList<>(config.endpointCommand()); + int selectorEnd = command.size() - (config.configFile().isPresent() ? 2 : 0); + int selectorStart = selectorEnd - config.endpoint().flags().size(); + command.subList(selectorStart, selectorEnd).clear(); + command.addAll(selectorStart, List.of("-S", pin.socketPath())); + command.add(1, "-N"); + ControlAcquisition acquisition = new ControlAcquisition(pin, identity, List.copyOf(command), budget, began); + Identity retained = read(carrier, acquisition.command(), acquisition.remaining(false)); + if (!identity.equals(retained)) + throw new TargetGoneException("the tmux endpoint changed during control acquisition"); + return acquisition; + } catch (RuntimeException failure) { + try { + pin.close(); + } catch (IOException cleanup) { + failure.addSuppressed(cleanup); + } + throw failure; + } + } + + /** + * Whether control clients are qualified on this daemon: tmux 3.7 or newer, where a development + * or candidate build of 3.7 itself ranks below it ({@code next-3.7 < 3.7-rc < 3.7}) and + * {@code 3.8-rc2} or {@code next-3.8} rank above. + */ + public static boolean supports(TmuxVersion version) { + return version.compareTo(MINIMUM) >= 0; + } + + /** + * Refuses a daemon whose control clients are not qualified, before any control client exists. + * + * @throws UnsupportedFeatureException below tmux 3.7 + */ + public static void requireSupported(TmuxVersion version) { + if (!supports(version)) { + throw new UnsupportedFeatureException( + "managed control acquisition requires tmux 3.7 or newer; daemon reports " + version); + } + } + + private static Identity read(ControlCarrier carrier, List command, Duration timeout) { + List endpoint = new ArrayList<>(command); + endpoint.add(1, "-N"); + CommandResult result; + try { + result = ProcessTransport.executeThroughCarrier( + carrier, + new CommandRequest(endpoint, List.of(List.of("display-message", "-p", FORMAT)), timeout, "")); + } catch (DispatchException failure) { + DispatchException denied = failure instanceof DispatchException.TimedOut + ? new DispatchException.TimedOut( + "control identity probe timed out", DispatchOutcome.NOT_DISPATCHED, failure) + : new DispatchException.Failed( + "control identity probe failed", DispatchOutcome.NOT_DISPATCHED, failure); + throw denied; + } + if (!result.succeeded()) { + throw new CommandRejectedException( + "cannot query the control endpoint" + ErrorText.suffix(result.stderr()), + "display-message", + result.exitCode(), + result.stderr()); + } + if (result.stdout().size() != 1) throw new MalformedResponseException("tmux reported no control identity"); + String[] fields = result.stdout().getFirst().split("\t", 4); + try { + if (fields.length != 4 || fields[3].isEmpty()) throw new NumberFormatException(); + long pid = Long.parseLong(fields[1]); + long start = Long.parseLong(fields[2]); + if (pid < 1 || start < 1) throw new NumberFormatException(); + return new Identity(fields[0], pid, start, fields[3]); + } catch (NumberFormatException failure) { + throw new MalformedResponseException("tmux reported an invalid control identity", failure); + } + } + + public List command() { + return command; + } + + public Identity identity() { + return identity; + } + + public void verify(long pid, OptionalLong start, String version) { + if (identity.pid() != pid + || !identity.version().equals(version) + || (start.isPresent() && identity.start() != start.getAsLong())) { + throw new TargetGoneException("the tmux server this handle belonged to has ended"); + } + } + + public Duration remaining(boolean dispatched) { + return remaining(budget, began, dispatched); + } + + private static Duration remaining(Duration budget, long began, boolean dispatched) { + Duration left = budget.minusNanos(System.nanoTime() - began); + if (left.isZero() || left.isNegative()) { + throw new DispatchException.TimedOut( + "control acquisition exceeded its deadline", + dispatched ? DispatchOutcome.UNKNOWN : DispatchOutcome.NOT_DISPATCHED, + null); + } + return left; + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + try { + pin.close(); + } catch (IOException failure) { + throw new IllegalStateException("could not remove the owned control endpoint", failure); + } + } + + public record Identity(String version, long pid, long start, String socket) {} +} diff --git a/libtmux/src/main/java/io/github/libtmux/internal/LocalSocketPin.java b/libtmux/src/main/java/io/github/libtmux/internal/LocalSocketPin.java new file mode 100644 index 000000000..2ba84d48f --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/internal/LocalSocketPin.java @@ -0,0 +1,95 @@ +package io.github.libtmux.internal; + +import io.github.libtmux.transport.ControlCarrier; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +/** A pin created only by carriers that own this filesystem. */ +public final class LocalSocketPin implements ControlCarrier.PinnedSocket { + private final Path directory; + private final Path alias; + private final AtomicBoolean closed = new AtomicBoolean(); + + private LocalSocketPin(Path directory, Path alias) { + this.directory = directory; + this.alias = alias; + } + + public static LocalSocketPin retain(String socketPath) throws IOException { + Path socket = Path.of(socketPath).toAbsolutePath(); + Map original = socketAttributes(socket); + List parents = new ArrayList<>(); + parents.add(Path.of(System.getProperty("java.io.tmpdir")).toAbsolutePath()); + Path peer = Objects.requireNonNull(socket.getParent()); + if (!parents.contains(peer)) parents.add(peer); + IOException rejected = new IOException("cannot retain a short Unix socket alias on the same filesystem"); + for (Path parent : parents) { + try { + return retainAt(parent, socket, original); + } catch (IOException failure) { + rejected.addSuppressed(failure); + } + } + throw rejected; + } + + private static LocalSocketPin retainAt(Path parent, Path socket, Map original) throws IOException { + Path directory = Files.createTempDirectory( + parent, ".lc-", PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + Path alias = directory.resolve("s"); + try { + // Darwin allows 103 pathname bytes; Linux's limit is slightly larger. + if (alias.toString().getBytes(StandardCharsets.UTF_8).length > 103) { + throw new IOException("the retained socket alias exceeds the Unix pathname limit"); + } + Files.createLink(alias, socket); + Map pinned = socketAttributes(alias); + if (!Objects.equals(original.get("dev"), pinned.get("dev")) + || !Objects.equals(original.get("ino"), pinned.get("ino")) + || !Files.isSameFile(socket, alias)) { + throw new IOException("the tmux socket changed while it was being retained"); + } + return new LocalSocketPin(directory, alias); + } catch (IOException | RuntimeException failure) { + try { + Files.deleteIfExists(alias); + Files.deleteIfExists(directory); + } catch (IOException cleanup) { + failure.addSuppressed(cleanup); + } + throw failure; + } + } + + private static Map socketAttributes(Path path) throws IOException { + Map attributes = Files.readAttributes(path, "unix:mode,dev,ino", LinkOption.NOFOLLOW_LINKS); + if (!(attributes.get("mode") instanceof Number mode) + || (mode.intValue() & 0xF000) != 0xC000 + || attributes.get("dev") == null + || attributes.get("ino") == null) { + throw new IOException("control acquisition requires a Unix socket with a stable inode"); + } + return attributes; + } + + @Override + public String socketPath() { + return alias.toString(); + } + + @Override + public void close() throws IOException { + if (!closed.compareAndSet(false, true)) return; + Files.deleteIfExists(alias); + Files.deleteIfExists(directory); + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/transport/ControlCarrier.java b/libtmux/src/main/java/io/github/libtmux/transport/ControlCarrier.java index 757a61ae9..6b720012a 100644 --- a/libtmux/src/main/java/io/github/libtmux/transport/ControlCarrier.java +++ b/libtmux/src/main/java/io/github/libtmux/transport/ControlCarrier.java @@ -1,6 +1,7 @@ package io.github.libtmux.transport; import java.io.IOException; +import java.time.Duration; import java.util.List; /** @@ -21,4 +22,31 @@ public interface ControlCarrier { * @throws IOException if the process cannot be started */ Process start(List command) throws IOException; + + /** + * Retains this realm's Unix socket against replacement before control initialization. + * + *

The alias must refer to the same socket inode for its lifetime, within a private directory + * on the same filesystem. Replacing the original pathname must never redirect the alias. + * The caller owns the returned pin; the carrier remains borrowed. + * + * @param socketPath the daemon's own socket path in this carrier's realm + * @param timeout the remaining acquisition budget + * @return the retained endpoint + * @throws IOException if pinning fails + * @throws UnsupportedOperationException when this carrier cannot retain an endpoint + */ + default PinnedSocket pinSocket(String socketPath, Duration timeout) throws IOException { + throw new UnsupportedOperationException("this carrier cannot retain a tmux socket"); + } + + /** A retained socket alias owned by one control client. */ + interface PinnedSocket extends AutoCloseable { + /** The alias pathname in the carrier's realm. */ + String socketPath(); + + /** Removes only the alias and directory created by this pin. */ + @Override + void close() throws IOException; + } } 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 86d2dae42..b759afd4b 100644 --- a/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java +++ b/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java @@ -3,6 +3,7 @@ import io.github.libtmux.exception.DispatchException; import io.github.libtmux.exception.ServerClosedException; import io.github.libtmux.internal.CommandStrings; +import io.github.libtmux.internal.LocalSocketPin; import io.github.libtmux.internal.ProcessTree; import io.github.libtmux.internal.Utf8; import java.io.ByteArrayOutputStream; @@ -182,10 +183,21 @@ public ProcessTransport(int maxConcurrentProcesses, int maxOutputBytes) { } @Override + @SuppressWarnings("ReferenceEquality") // Only this starter owns the local filesystem. public Optional controlCarrier() { - return Optional.of(command -> { - requireOpen(); - return starter.start(command); + return Optional.of(new ControlCarrier() { + @Override + public Process start(List command) throws IOException { + requireOpen(); + return starter.start(command); + } + + @Override + public PinnedSocket pinSocket(String socketPath, Duration timeout) throws IOException { + requireOpen(); + if (starter != SYSTEM_STARTER) return ControlCarrier.super.pinSocket(socketPath, timeout); + return LocalSocketPin.retain(socketPath); + } }); } diff --git a/libtmux/src/test/java/io/github/libtmux/ControlFloorVectorsTest.java b/libtmux/src/test/java/io/github/libtmux/ControlFloorVectorsTest.java new file mode 100644 index 000000000..571bec443 --- /dev/null +++ b/libtmux/src/test/java/io/github/libtmux/ControlFloorVectorsTest.java @@ -0,0 +1,31 @@ +package io.github.libtmux; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.github.libtmux.exception.UnsupportedFeatureException; +import io.github.libtmux.internal.ControlAcquisition; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Control needs tmux 3.7: its own development and candidate builds do not qualify, later ones do. */ +final class ControlFloorVectorsTest { + + @Test + void theControlFloorFollowsTheReleaseTrain() { + List refused = List.of("3.6b", "next-3.7", "3.7-rc", "3.7-rc2", "3.7-rc9", "3.3a", "next-3.6"); + List supported = + List.of("3.7", "3.7a", "3.7d", "3.8-rc", "3.8-rc2", "next-3.8", "next-3.9", "3.10", "4.0"); + for (String version : refused) { + assertEquals(false, ControlAcquisition.supports(TmuxVersion.parse(version)), version); + assertThrows( + UnsupportedFeatureException.class, + () -> ControlAcquisition.requireSupported(TmuxVersion.parse(version)), + version); + } + for (String version : supported) { + assertEquals(true, ControlAcquisition.supports(TmuxVersion.parse(version)), version); + ControlAcquisition.requireSupported(TmuxVersion.parse(version)); + } + } +} diff --git a/libtmux/src/test/java/io/github/libtmux/ServerTest.java b/libtmux/src/test/java/io/github/libtmux/ServerTest.java index 5063fdb04..04af7407c 100644 --- a/libtmux/src/test/java/io/github/libtmux/ServerTest.java +++ b/libtmux/src/test/java/io/github/libtmux/ServerTest.java @@ -768,11 +768,11 @@ public CommandResult execute(CommandRequest request) { if (request.commands().stream() .flatMap(List::stream) .anyMatch(word -> word.contains("#{pid} #{version}"))) { - return new CommandResult(0, List.of("4242 3.6"), List.of()); + return new CommandResult(0, List.of("4242 3.7c"), List.of()); } - return GroupedTmux.execute(request, 4242L, "3.6", command -> switch (command.get(0)) { + return GroupedTmux.execute(request, 4242L, "3.7c", command -> switch (command.get(0)) { case "display-message" -> - new CommandResult(0, List.of(String.join(separator, "4242", "3.6", "1790000000")), List.of()); + new CommandResult(0, List.of(String.join(separator, "4242", "3.7c", "1790000000")), List.of()); case "list-sessions" -> new CommandResult(0, List.of(String.join(separator, "$0", "only", "1", "1")), List.of()); case "list-windows" -> 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 b83aa8ea0..742ca5871 100644 --- a/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java +++ b/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java @@ -78,7 +78,7 @@ void aBlockedControlWriteDoesNotOccupyTheCallersCarrier(@TempDir Path directory) sleep 5 """); - try (ControlClient client = ControlClient.attachUnfenced(config, new SessionId("$0"))) { + try (ControlClient client = attachFake(config)) { Thread blocked = Thread.ofVirtual().start(() -> { try { client.send(List.of("display-message", "x".repeat(1_048_576)), Duration.ofSeconds(4)); @@ -149,7 +149,7 @@ private static String capturedWatchRequest(Path directory, int attempt, String t sleep 5 """); - try (ControlClient client = ControlClient.attachUnfenced(config, new SessionId("$0"))) { + try (ControlClient client = attachFake(config)) { client.watch("javatest", target, "#{session_name}"); assertTrue(awaitFile(captured), "the watch request never reached the fake server"); return Files.readString(captured).strip(); @@ -167,7 +167,7 @@ void aTimedOutReplyMakesTheStreamUnavailableForLaterRequests(@TempDir Path direc sleep 1 """); - try (ControlClient client = ControlClient.attachUnfenced(config, new SessionId("$0")); + try (ControlClient client = attachFake(config); EventSubscription events = client.subscribeEvents(1)) { DispatchException.TimedOut failure = assertThrows( DispatchException.TimedOut.class, @@ -189,7 +189,7 @@ void closingTheClientWakesAWaitingSubscriber(@TempDir Path directory) throws Exc answer IFS= read -r never """); - ControlClient client = ControlClient.attachUnfenced(config, new SessionId("$0")); + ControlClient client = attachFake(config); EventSubscription output = client.subscribeOutput(1); CountDownLatch entered = new CountDownLatch(1); FutureTask>> waiting = new FutureTask<>(() -> { @@ -221,7 +221,7 @@ void aNonPositiveTimeoutIsRejectedBeforeDispatch(@TempDir Path directory) throws done """); - try (ControlClient client = ControlClient.attachUnfenced(config, new SessionId("$0"))) { + try (ControlClient client = attachFake(config)) { assertThrows(IllegalArgumentException.class, () -> client.send(List.of("list-windows"), Duration.ZERO)); assertTrue(client.send("list-panes").succeeded(), "rejection wrote nothing to the stream"); } @@ -236,7 +236,7 @@ void aRejectedNulDoesNotStealTheNextRequestsReply(@TempDir Path directory) throw done """); - try (ControlClient client = ControlClient.attachUnfenced(config, new SessionId("$0"))) { + try (ControlClient client = attachFake(config)) { assertThrows( IllegalArgumentException.class, () -> client.send(List.of("display-message", "contains\0nul"), Duration.ofMillis(200))); @@ -261,7 +261,7 @@ void interruptionIsNotReportedAsADeadlineExpiry(@TempDir Path directory) throws IFS= read -r never """); - try (ControlClient client = ControlClient.attachUnfenced(config, new SessionId("$0"))) { + try (ControlClient client = attachFake(config)) { CountDownLatch entered = new CountDownLatch(1); FutureTask waiting = new FutureTask<>(() -> { entered.countDown(); @@ -293,16 +293,15 @@ void anAttachErrorIsNotAcceptedAsAReadyClient(@TempDir Path directory) throws Ex printf '%%begin 100 1 0\nattach refused\n%%error 100 1 0\n' """); - assertThrows(LibTmuxException.class, () -> ControlClient.attachUnfenced(config, new SessionId("$0"))); + assertThrows(LibTmuxException.class, () -> attachFake(config)); } @Test void anAttachDeadlineKeepsItsTimeoutType(@TempDir Path directory) throws Exception { ServerConfig config = fakeTmux(directory, "sleep 5\n"); - DispatchException.TimedOut timeout = assertThrows( - DispatchException.TimedOut.class, - () -> ControlClient.attachUnfenced(config, new SessionId("$0"), Duration.ofMillis(100))); + DispatchException.TimedOut timeout = + assertThrows(DispatchException.TimedOut.class, () -> attachFake(config, Duration.ofMillis(100))); assertEquals(DispatchOutcome.UNKNOWN, timeout.outcome()); } @@ -319,7 +318,7 @@ void repeatedTimeoutAndCloseDoNotLeaveClients(@TempDir Path directory) throws Ex """); int before = controlThreads(); for (int cycle = 0; cycle < 12; cycle++) { - ControlClient client = ControlClient.attachUnfenced(config, new SessionId("$0")); + ControlClient client = attachFake(config); DispatchException.TimedOut timeout = assertThrows( DispatchException.TimedOut.class, () -> client.send(List.of("list-windows"), Duration.ofMillis(80))); @@ -349,7 +348,7 @@ void aClientWhoseOutputEndedIsNotAliveWhileItsProcessLingers(@TempDir Path direc sleep 5 """.replace("SUBSCRIBED", subscribed.toString())); - try (ControlClient client = ControlClient.attachUnfenced(config, new SessionId("$0"), Duration.ofSeconds(2))) { + try (ControlClient client = attachFake(config, Duration.ofSeconds(2))) { EventSubscription output = client.subscribeOutput(4); // Runs on the thread that delivers the end, at the moment it is delivered. var aliveAtTheEnd = new java.util.concurrent.CompletableFuture(); @@ -374,7 +373,7 @@ void stderrCannotBlockTheOpeningControlReply(@TempDir Path directory) throws Exc while read_request; do answer; done """); - try (ControlClient client = ControlClient.attachUnfenced(config, new SessionId("$0"), Duration.ofSeconds(2))) { + try (ControlClient client = attachFake(config, Duration.ofSeconds(2))) { assertTrue(client.isAlive()); } } @@ -391,7 +390,7 @@ void closingAControlClientReclaimsDescendantsThatInheritedItsPipes(@TempDir Path """); long child = -1; try { - ControlClient client = ControlClient.attachUnfenced(config, new SessionId("$0")); + ControlClient client = attachFake(config); assertTrue(awaitFile(childFile), "the fake control client never started its descendant"); child = Long.parseLong(Files.readString(childFile).trim()); @@ -422,7 +421,7 @@ void aWriterFailureReclaimsDescendantsBeforeKillingTheControlProcess(@TempDir Pa """); long child = -1; try { - try (ControlClient client = ControlClient.attachUnfenced(config, new SessionId("$0"))) { + try (ControlClient client = attachFake(config)) { 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()); @@ -453,7 +452,7 @@ void aCharacterSplitAcrossOutputLinesArrivesWhole(@TempDir Path directory) throw answer IFS= read -r never """); - try (ControlClient client = ControlClient.attachUnfenced(config, new SessionId("$0")); + try (ControlClient client = attachFake(config); EventSubscription output = client.subscribeOutput(8)) { client.send("display-message"); @@ -483,7 +482,7 @@ void extendedOutputIsOutput(@TempDir Path directory) throws Exception { answer IFS= read -r never """); - try (ControlClient client = ControlClient.attachUnfenced(config, new SessionId("$0")); + try (ControlClient client = attachFake(config); EventSubscription output = client.subscribeOutput(8)) { client.send("display-message"); @@ -508,8 +507,19 @@ void extendedOutputIsOutput(@TempDir Path directory) throws Exception { printf 'unknown command: %s3.7d\\n' "${argument%'#{version}'}" >&2 exit 1;; esac + if [ "$argument" = '#{version}\t#{pid}\t#{start_time}\t#{socket_path}' ]; then + printf '3.7d\t4242\t10\t/fake-control\n'; exit + fi done - read_request() { IFS= read -r request && IFS= read -r marker; } + read_request() { + while IFS= read -r request && IFS= read -r marker; do + case "$request" in + *'#{start_time} #{pid} #{version}'*) answer '10 4242 3.7d';; + *) return 0;; + esac + done + return 1 + } answer() { printf '%%begin 1 1 1\\n' for line in "$@"; do printf '%s\\n' "$line"; done @@ -519,6 +529,34 @@ void extendedOutputIsOutput(@TempDir Path directory) throws Exception { } """; + private static ControlClient attachFake(ServerConfig config) { + return attachFake(config, Duration.ofSeconds(30)); + } + + private static ControlClient attachFake(ServerConfig config, Duration timeout) { + ControlCarrier carrier = new ControlCarrier() { + @Override + public Process start(List command) throws java.io.IOException { + return new ProcessBuilder(command).start(); + } + + @Override + public PinnedSocket pinSocket(String socketPath, Duration remaining) { + assertEquals("/fake-control", socketPath); + return new PinnedSocket() { + @Override + public String socketPath() { + return "/fake-control-pin"; + } + + @Override + public void close() {} + }; + } + }; + return ControlClient.attach(carrier, config, new SessionId("$0"), 4242, OptionalLong.of(10), "3.7d", timeout); + } + private static ServerConfig fakeTmux(Path directory, String body) throws Exception { Path fakeTmux = directory.resolve("tmux"); Files.writeString(fakeTmux, PRELUDE + body); From 24c5539baa730b89122d78a9e06c22ac39bdac35 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 04:28:37 -0500 Subject: [PATCH 05/18] Docs(fix): Document per-feature tmux requirements why: The guides still described one support range and a control client that attaches on any supported daemon, and the documentation checks ran every control example everywhere. what: - Guides, module READMEs and CONTRIBUTING state that managed control needs stable tmux 3.7 and a pinnable socket, name the 3.4 boundary for named buffers and managed paste, the exact-3.7 break-pane rule and the 3.3a layout rule, and keep the 3.3a floor for everything else. - A `control:` directive for Java and Kotlin snippets and `scala-io-control` for Scala run normally from stable 3.7 and assert UnsupportedFeatureException with no attached client below it, so every lane still compiles and exercises the examples. - The Python workspace backend section names the optional-policy marker, and the workspace CLI README and guide say no released Python libtmux declares it yet. --- .github/CONTRIBUTING.md | 10 +++---- README.md | 9 ++++--- .../GenerateScalaDocumentationSuite.kt | 27 +++++++++++++++---- docs/README.md | 7 +++++ docs/guide/getting-started.md | 4 ++- docs/guide/mcp.md | 4 ++- docs/guide/scala/compatibility.md | 9 ++++++- docs/guide/scala/ownership.md | 4 ++- docs/guide/scala/streaming.md | 5 ++-- docs/guide/streaming.md | 11 ++++++-- docs/guide/testing.md | 11 +++++--- docs/guide/workspace-cli.md | 10 +++++-- docs/internals/tmux-behaviour.md | 22 ++++++++------- .../io/github/libtmux/docs/Documentation.java | 5 +++- .../docs/DocumentationSnippetsTest.java | 15 ++++++++++- .../java/io/github/libtmux/docs/Snippet.java | 3 +++ examples/README.md | 3 +++ libtmux-kotlin/README.md | 5 ++++ libtmux-kotlin/build.gradle.kts | 16 +++++++++++ libtmux-scala-cats/README.md | 7 ++--- libtmux-scala-ox/README.md | 7 ++--- libtmux-workspace-cli/README.md | 6 ++++- libtmux/README.md | 1 + 23 files changed, 157 insertions(+), 44 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4e4ae6cae..2b45e5af5 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -30,11 +30,11 @@ answerable for how the whole library is tested. ## Building -You need JDK 25 or newer and tmux on `PATH`. Nothing else — Gradle provisions -the toolchain, and the library has no runtime dependencies. The workspace CLI -adds its own runtime dependencies; its terminal regression tests also require -Python 3 for the standard-library PTY driver and Bash on `PATH` for interactive -completion tests. On macOS, install Bash with Homebrew; the system Bash lacks +You need JDK 25 or newer and tmux on `PATH`. Gradle provisions the toolchain, +and the library has no runtime dependencies. Native terminal test fixtures need +Python 3 for its standard-library PTY driver. The workspace CLI adds its own +runtime dependencies; its interactive completion tests also need Bash on +`PATH`. On macOS, install Bash with Homebrew; the system Bash lacks the completion test's Readline hooks. `./gradlew` is the only supported entry point. A locally installed `gradle` is diff --git a/README.md b/README.md index 5eb1e349a..85f8c1bd6 100644 --- a/README.md +++ b/README.md @@ -605,7 +605,7 @@ Reference: [`Flows`][scala-ox-flows] on libtmux.org and [`Flows.liveView`][scala-ox-flows] emits the current view of a server when the flow starts, then every newer one: - + ```scala import _root_.ox.{supervised, useCloseableInScope} import io.github.libtmux.scaladsl.{config => _, *} @@ -715,8 +715,11 @@ $ tmux-workspace load \ named module `io.github.libtmux`, and the jars carry no OSGi bundle headers. - **Linux, including WSL.** CI runs on Ubuntu. macOS is supported on a best-effort basis, and CI coverage for it is highly limited. -- **tmux 3.3a through 3.7c.** The whole real-tmux suite runs against every one - of those releases, and each lane checks it ran the tmux it is named after: +- **tmux 3.3a through 3.7c** for ordinary operations. The whole real-tmux suite + runs against every one of those releases, and each lane checks it ran the tmux + it is named after. Managed control needs stable tmux 3.7 or newer and a + carrier that can pin its socket; the other feature requirements are in the + [migration notes](MIGRATION.md#optional-tmux-requirements): ```console $ ./gradlew testTmuxMatrix -PlibtmuxMatrix=/path/to/tmux/builds diff --git a/build-logic/conventions/src/main/kotlin/io/github/libtmux/buildlogic/GenerateScalaDocumentationSuite.kt b/build-logic/conventions/src/main/kotlin/io/github/libtmux/buildlogic/GenerateScalaDocumentationSuite.kt index 41f51d165..28e678a57 100644 --- a/build-logic/conventions/src/main/kotlin/io/github/libtmux/buildlogic/GenerateScalaDocumentationSuite.kt +++ b/build-logic/conventions/src/main/kotlin/io/github/libtmux/buildlogic/GenerateScalaDocumentationSuite.kt @@ -26,7 +26,8 @@ import org.gradle.api.tasks.TaskAction * Each fence carries a directive on the line before it, ``: * `sync` runs the code against a fresh tmux, `io` runs a Cats `IO` to completion within the * 15-second deadline every documented snippet has, `reject` asserts the code fails to compile with - * `detail` in the error, and `build` marks an sbt build fragment the consumer checks exercise + * `detail` in the error, `io-control` and `sync-control` also check refusal below tmux 3.7, and + * `build` marks an sbt build fragment the consumer checks exercise * instead. An unclassified fence fails the build, and so * does a directive no fence follows. A final inventory test pins each document's digest and fence * count, so a document edited after the suite was generated cannot pass on the old code. @@ -87,7 +88,7 @@ abstract class GenerateScalaDocumentationSuite : DefaultTask() { val beforeIndex = (start - 1 downTo 0).firstOrNull { lines[it].isNotBlank() } val before = beforeIndex?.let { lines[it].trim() }.orEmpty() val directive = requireNotNull(DIRECTIVE.matchEntire(before)) { - "$where has an unclassified Scala fence; use a snippet: scala-sync, scala-io, scala-reject or scala-build directive" + "$where has an unclassified Scala fence; use a snippet: scala-sync, scala-sync-control, scala-io, scala-io-control, scala-reject or scala-build directive" } unusedDirectives.remove(beforeIndex) val (mode, id, detail) = directive.destructured @@ -123,14 +124,30 @@ abstract class GenerateScalaDocumentationSuite : DefaultTask() { .joinToString(",\n") { quoted(relative(root, it)) + " -> " + quoted(digest(it.readBytes())) } val names = runnable.joinToString(",\n") { quoted(it.name(root)) } val declarations = runnable.filterNot { it.mode == "reject" }.joinToString("\n") { snippet -> - val body = if (snippet.mode == "sync") "DocumentationRuntime.requireUnit {\n${snippet.code}\n}" else "{\n${snippet.code}\n}" - val result = if (snippet.mode == "sync") "Unit" else "_root_.cats.effect.IO[Unit]" + val synchronous = snippet.mode == "sync" || snippet.mode == "sync-control" + val body = if (synchronous) "DocumentationRuntime.requireUnit {\n${snippet.code}\n}" else "{\n${snippet.code}\n}" + val result = if (synchronous) "Unit" else "_root_.cats.effect.IO[Unit]" "private[docs] object ${snippet.objectName} {\ndef run(config: io.github.libtmux.ServerConfig): $result = $body\n}\n" } val cases = runnable.joinToString("\n") { snippet -> val invoke = snippet.objectName + ".run(fixture.config)" val body = when (snippet.mode) { "sync" -> "io.github.libtmux.scaladsl.fixture.OwnedTmux.use { fixture => $invoke }" + "sync-control" -> + "io.github.libtmux.scaladsl.fixture.OwnedTmux.use { fixture =>\n" + + "if (fixture.supportsControl) $invoke\n" + + "else {\n" + + "intercept[io.github.libtmux.exception.UnsupportedFeatureException]($invoke)\n" + + "assert(fixture.server.clients().isEmpty(), \"a refused snippet attached a client\")\n" + + "}\n}" + "io-control" -> + "io.github.libtmux.scaladsl.fixture.OwnedTmux.use { fixture =>\n" + + "val program = $invoke.timeout(_root_.scala.concurrent.duration.FiniteDuration(15, java.util.concurrent.TimeUnit.SECONDS))\n" + + "if (fixture.supportsControl) program.unsafeRunSync()(using _root_.cats.effect.unsafe.IORuntime.global)\n" + + "else {\n" + + "intercept[io.github.libtmux.exception.UnsupportedFeatureException](program.unsafeRunSync()(using _root_.cats.effect.unsafe.IORuntime.global))\n" + + "assert(fixture.server.clients().isEmpty(), \"a refused snippet attached a client\")\n" + + "}\n}" "io" -> "io.github.libtmux.scaladsl.fixture.OwnedTmux.use { fixture =>\n" + "val evaluated = new java.util.concurrent.atomic.AtomicBoolean(false)\n" + @@ -175,7 +192,7 @@ $cases val IGNORED = setOf(".git", ".gradle", ".bsp", ".metals", ".idea", "target", "build", "node_modules") val OPENING = Regex("^ {0,3}(`{3,}|~{3,})[ \\t]*([^\\s`]*).*$") val UNSUPPORTED_SCALA_FENCE = Regex("(?i)^[ \\t>]*(?:`{3,}|~{3,})[ \\t]*(?:scala\\S*|sbt)(?:[ \\t].*)?$") - val DIRECTIVE = Regex("^$") + val DIRECTIVE = Regex("^$") fun relative(root: File, file: File): String = root.toPath().relativize(file.toPath()).toString().replace('\\', '/') diff --git a/docs/README.md b/docs/README.md index 6589b36ee..bbd7f76e6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -121,6 +121,7 @@ Say otherwise with an HTML comment directly above the fence: | directive | means | | --- | --- | | *(none)* | compiles, and runs against live tmux | +| `` | runs on tmux 3.7+; older daemons must refuse before attaching | | `` | runs, and must fail with exactly that | | `` | the compiler must reject it, with a diagnostic containing that phrase | | `` | compiles; not run, for the stated reason | @@ -309,3 +310,9 @@ that, and each row says where it looks. Not `docs/decisions` or `docs/internals`: those are dated or historical records. Holding them to today's API would either break the build or quietly rewrite history, and neither is what a record is for. + +Control-specific Scala examples use `scala-io-control` in place of `scala-io`, +or `scala-sync-control` in place of `scala-sync`. +They compile on every lane, run normally from tmux 3.7, and must raise +`UnsupportedFeatureException` before attachment on older daemons. Java's +`control` directive checks the same boundary. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 496e65d74..3e9273961 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -227,8 +227,10 @@ just created. ## Watching output -A control client stays attached and pushes terminal output as it happens: +A control client requires tmux 3.7 or newer and a carrier that can pin +its socket. It stays attached and pushes terminal output as it happens: + ```java // Given: Server server, Session session try (ControlClient client = server.control(session); diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index befa29af2..e04d033bf 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -171,7 +171,9 @@ invocation, and everything at or below it waits until it is finished. ## Being told, instead of asking -tmux can push. A control client that has attached is told when a window appears +tmux can push. Managed control requires tmux 3.7+ and a carrier-owned +pinned socket. The MCP polling tools retain the core 3.3a minimum. A control +client that has attached is told when a window appears or a session is renamed, and `refresh-client -B` registers a format tmux re-expands on its own one-second timer and reports **only when the value differs**. diff --git a/docs/guide/scala/compatibility.md b/docs/guide/scala/compatibility.md index 789ba47a1..2ec53a321 100644 --- a/docs/guide/scala/compatibility.md +++ b/docs/guide/scala/compatibility.md @@ -46,8 +46,15 @@ release for MiMa or `tasty-mima` to compare against. ## Inherited feature boundaries -The facade preserves Java's version guards. [Named buffer deletion][buffers] +The facade preserves Java's version guards. Managed control, FS2/Ox +observations and live views require tmux 3.7+ and a carrier-owned +pinned socket. Ordinary operations remain available from 3.3a. Control-positive +cases run only on eligible lanes; lower lanes assert before-effect refusal. + +[Named buffer deletion][buffers] rejects tmux before 3.4 because those versions can delete the wrong buffer. +The same boundary covers named rename and managed-paste cleanup. Direct named +`paste-buffer -d` retains its native exact lookup on 3.3a. [`Shell.capturing`][java-shell] rejects tmux 3.3a and 3.4, which lose the requested output. Java's tmux matrix asserts these unsupported results rather than skip the contract or substitute an empty successful result. diff --git a/docs/guide/scala/ownership.md b/docs/guide/scala/ownership.md index af1d685ca..f1391fed8 100644 --- a/docs/guide/scala/ownership.md +++ b/docs/guide/scala/ownership.md @@ -84,7 +84,9 @@ its owner. [`Control.attach`][control] on a captured session owns a separate process attachment to that capture's process, started by the session's transport. -`Control.attachUnfenced` on a config and session id does not check the process. +Both forms require tmux 3.7+ and a carrier-owned pinned socket before +control initialization. `Control.attachUnfenced` selects the daemon answering +at call time; it does not require a previously captured incarnation. Releasing either stops its admitted requests before closing the attachment and preserves the daemon. diff --git a/docs/guide/scala/streaming.md b/docs/guide/scala/streaming.md index 857e9b39c..92ad5c5dd 100644 --- a/docs/guide/scala/streaming.md +++ b/docs/guide/scala/streaming.md @@ -1,6 +1,7 @@ # Streaming -The Cats module adapts Java control subscriptions to FS2. Acquire an attachment, +The Cats module adapts Java control subscriptions to FS2. Managed control +requires tmux 3.7+ and a carrier-owned pinned socket. Acquire an attachment, then an observation, then start the producer. An observation has one active stream consumer; use FS2's explicit broadcast operations if several consumers need the same values. Starting another reader on the same observation fails. @@ -11,7 +12,7 @@ Here `config` selects an existing server with a session. The subscription is registered before the rename. Evaluating the returned `IO` performs the work; constructing it alone does not. - + ```scala import _root_.cats.effect.IO import io.github.libtmux.control.Notification diff --git a/docs/guide/streaming.md b/docs/guide/streaming.md index a907a1611..b193a08c5 100644 --- a/docs/guide/streaming.md +++ b/docs/guide/streaming.md @@ -93,9 +93,12 @@ origin. Treat exhaustion as "carry on" rather than as an error. ## Pushed output -A control client stays attached and pushes terminal output as tmux produces it, -rather than being asked: +Managed control requires tmux 3.7 or newer and a carrier that can pin +its socket before initialization. Ordinary polling and channels retain the +3.3a floor. An eligible control client stays attached and pushes terminal +output as tmux produces it: + ```java // Given: Server server, Session session try (ControlClient client = server.control(session); @@ -130,6 +133,7 @@ time on the consuming thread. It ends when the subscription closes, so a timer that closes it bounds the whole read, and closing the stream closes the subscription: + ```java // Given: Server server, Session session try (ControlClient client = server.control(session); @@ -174,6 +178,7 @@ subscription's own bounded buffer keeps filling regardless, and a full buffer still reports its loss as a `Delivery.Gap`, delivered like any other element. Pausing tmux's own push is `pause-after`, further down. + ```java // Given: Server server, Session session try (ControlClient client = server.control(session); @@ -221,6 +226,7 @@ The same client is told about changes as they happen — a window created or renamed, a session switched, a layout moved — without asking. Each arrives as a `ControlEvent`, and `notification()` is its typed reading, for matching on: + ```java // Given: Server server, Session session try (ControlClient client = server.control(session); @@ -251,6 +257,7 @@ A notification says that something changed, not what everything now is. fresh capture, and each capture that differs from the last is published as a numbered view: + ```java // Given: Session session try (ServerMirror mirror = ServerMirror.open(session)) { diff --git a/docs/guide/testing.md b/docs/guide/testing.md index e49229f75..3929b80a0 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -83,6 +83,11 @@ about, because both are silent: - `pane_floating_flag` does not exist before tmux 3.7, and the format expands to nothing rather than to zero. `Pane.floating()` is an `Optional` that is empty when the running tmux cannot say, rather than defaulting to `false`. -- tmux 3.7 exactly ends the whole server when `break-pane` has to name the new - window itself, and silently discards a name it is given. `Pane.breakOut()` - works around both. +- Exact tmux 3.7 can end the server on an unnamed `break-pane` and ignores an + explicit name in its multi-pane branch. `Pane.breakOut()` refuses there; + `Pane.breakOut(name)` supplies and then reapplies the explicit name. + +Control-positive cases run from tmux 3.7. Lower matrix lanes retain +ordinary operations and assert that control is refused before attachment. +Named buffer and managed-paste cases assert their own 3.4 boundary. These +feature requirements do not change the core 3.3a minimum. diff --git a/docs/guide/workspace-cli.md b/docs/guide/workspace-cli.md index 52cc7ea4f..ebef4ef48 100644 --- a/docs/guide/workspace-cli.md +++ b/docs/guide/workspace-cli.md @@ -316,6 +316,10 @@ the `default`, `minimal`, `window`, `pane` and `verbose` presets, or a template. - Python shell commands and extension workspaces use `TMUX_WORKSPACE_PYTHON` (default `python3`) with tmuxp 1.74.0 installed. +- The interpreter's libtmux must also declare + `TMUX_OPTIONAL_FEATURE_POLICY_VERSION >= 2`. No released libtmux does yet; it + comes with [tmux-python/libtmux#780](https://github.com/tmux-python/libtmux/pull/780). + Until then, install libtmux from that source, or skip `shell` and extensions. - The version is checked before any input workspace changes. - Nonempty `plugins`, `workspace_builder`, or `workspace_builder_paths` select the bridge. @@ -354,8 +358,10 @@ the `default`, `minimal`, `window`, `pane` and `verbose` presets, or a template. mutation and cleanup behavior. The Python bridge requires tmuxp 1.74.0 and a Python libtmux whose minimum is -tmux 3.3a or newer. It refuses an older backend before running extensions or -shell code; see the [migration notes](../../MIGRATION.md). +tmux 3.3a or newer and whose integer optional-feature policy marker is at least +2. Both the runtime probe and the child check it before extension or shell code +runs; see the [migration notes](../../MIGRATION.md). Native loading retains the +core floor and does not acquire control. ## Subprocesses diff --git a/docs/internals/tmux-behaviour.md b/docs/internals/tmux-behaviour.md index b6ba6f826..a03d2ebac 100644 --- a/docs/internals/tmux-behaviour.md +++ b/docs/internals/tmux-behaviour.md @@ -113,15 +113,19 @@ anything matched filters a capture instead. ## select-layout can crash the server, on 3.3a only -`select-layout` given a string tmux cannot parse — an unknown built-in name or -a malformed serialized layout — returns an error on every lane except 3.3a, -where the server itself exits, taking every session on that socket with it. -Both the built-in names (checked against an enum) and a serialized layout -string (checked by recomputing tmux's own rotate-and-add checksum over the -layout body, four hex digits at the front) are validated before dispatch, so -this library never hands `select-layout` anything it has not already verified -tmux itself would have written or would recognize. `main-horizontal-mirrored` -and `main-vertical-mirrored` only exist from tmux 3.5. +On tmux 3.3a, an unknown layout without a hexadecimal checksum prefix can +end the server and every session on its socket. `layout_parse` leaves the +error cause unset on that path; upstream commit +[`f10854cf`](https://github.com/tmux/tmux/commit/f10854cfc5e43f646b02b3031bfc4bc3f5f6c513) +initializes it, first released in 3.4. A bad checksum already reports an error +on 3.3a. + +Typed layouts validate built-in names and serialized geometry and checksums. +Literal `select-layout` commands also validate operands before every batch +effect on 3.3a. The check accepts built-in prefixes such as `tile`, skips +operands ignored by `-n`, `-p` or `-E`, and requires a serialized operand with +`-o`. A missing operand keeps tmux's native selection or previous-layout +behavior. Mirrored presets require tmux 3.5. ## %output is cut by byte count, not by character diff --git a/docs/src/test/java/io/github/libtmux/docs/Documentation.java b/docs/src/test/java/io/github/libtmux/docs/Documentation.java index 14b571029..d4d73f391 100644 --- a/docs/src/test/java/io/github/libtmux/docs/Documentation.java +++ b/docs/src/test/java/io/github/libtmux/docs/Documentation.java @@ -88,6 +88,9 @@ private static Snippet.Expectation expectationOf(String directive, Path file) { if (directive.startsWith("does-not-compile:") && directive.length() > "does-not-compile:".length()) { return Snippet.Expectation.DOES_NOT_COMPILE; } + if (directive.startsWith("control:") && directive.length() > "control:".length()) { + return Snippet.Expectation.CONTROL; + } if (directive.startsWith("throws:") && directive.length() > "throws:".length()) { return Snippet.Expectation.THROWS; } @@ -101,7 +104,7 @@ private static Snippet.Expectation expectationOf(String directive, Path file) { // exists to prevent. Naming the file because a typo here is silent otherwise. throw new IllegalArgumentException("unknown snippet directive '" + directive + "' in " + file + "; expected 'does-not-compile: ', 'throws: ', 'compile-only: '" - + " or 'skip: '"); + + ", 'control: ' or 'skip: '"); } /** diff --git a/docs/src/test/java/io/github/libtmux/docs/DocumentationSnippetsTest.java b/docs/src/test/java/io/github/libtmux/docs/DocumentationSnippetsTest.java index 3fb1d99ea..53e7c754f 100644 --- a/docs/src/test/java/io/github/libtmux/docs/DocumentationSnippetsTest.java +++ b/docs/src/test/java/io/github/libtmux/docs/DocumentationSnippetsTest.java @@ -3,6 +3,7 @@ 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -10,7 +11,9 @@ import io.github.libtmux.Pane; import io.github.libtmux.Server; import io.github.libtmux.Session; +import io.github.libtmux.TmuxVersion; import io.github.libtmux.Window; +import io.github.libtmux.exception.UnsupportedFeatureException; import io.github.libtmux.junit5.TmuxExtension; import io.github.libtmux.junit5.TmuxSocketPath; import java.io.IOException; @@ -141,7 +144,17 @@ void theSnippetIsWhatItClaimsToBe(Snippet snippet, Server server, TmuxSocketPath snippet.where() + " failed with the wrong thing: " + thrown); return; } - if (snippet.expectation() == Snippet.Expectation.RUNS) { + if (snippet.expectation() == Snippet.Expectation.CONTROL) { + TmuxVersion version = server.version(); + if (!io.github.libtmux.internal.ControlAcquisition.supports(version)) { + assertThrows( + UnsupportedFeatureException.class, () -> compiler.run(compiled, bindings(server, socket))); + assertTrue(server.clients().isEmpty(), "a refused snippet attached a client"); + return; + } + } + if (snippet.expectation() == Snippet.Expectation.RUNS + || snippet.expectation() == Snippet.Expectation.CONTROL) { compiler.run(compiled, bindings(server, socket)); } } finally { diff --git a/docs/src/test/java/io/github/libtmux/docs/Snippet.java b/docs/src/test/java/io/github/libtmux/docs/Snippet.java index 252d8e1ce..c7d2ed8b4 100644 --- a/docs/src/test/java/io/github/libtmux/docs/Snippet.java +++ b/docs/src/test/java/io/github/libtmux/docs/Snippet.java @@ -24,6 +24,9 @@ enum Expectation { */ RUNS, + /** Runs on tmux 3.7+; older daemons must refuse control before attachment. */ + CONTROL, + /** * Must compile, but is not run, and the directive has to say why. * diff --git a/examples/README.md b/examples/README.md index c9452dab1..46d11a197 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,6 +24,9 @@ they are the part of a project nobody compiles and everybody reads first. | [`ResourceBoundaries`](src/main/scala/io/github/libtmux/scaladsl/examples/ResourceBoundaries.scala) | Scala, Cats: borrowing a Java client, and cancelling one dispatched wait | | [`WatchWithOx`](src/main/scala/io/github/libtmux/scaladsl/examples/WatchWithOx.scala) | Scala, Ox: a live view as a `Flow` in a supervised fork, watching a window appear | +Control and live-view examples require tmux 3.7+ and a carrier-owned +pinned socket. Other examples retain the core 3.3a minimum. + ## Run one The [common API programs](api/README.md) provide separate complete Java, diff --git a/libtmux-kotlin/README.md b/libtmux-kotlin/README.md index c74dcd39c..b72c8db5f 100644 --- a/libtmux-kotlin/README.md +++ b/libtmux-kotlin/README.md @@ -225,6 +225,10 @@ withServer(config) { server -> [`withControl`][with-control] attaches a control client, and `output` turns a [`Delivery`][delivery] stream into a `Flow`: +Managed control requires tmux 3.7+ and a carrier-owned pinned socket. +Ordinary suspend operations retain the core 3.3a minimum. + + ```kotlin // Given: config: ServerConfig import io.github.libtmux.control.Delivery @@ -256,6 +260,7 @@ withServer(config) { server -> ### `Server.withLiveState`: the live server as a `StateFlow` + ```kotlin // Given: config: ServerConfig import kotlinx.coroutines.flow.first diff --git a/libtmux-kotlin/build.gradle.kts b/libtmux-kotlin/build.gradle.kts index f6a06b443..52bfbe1b6 100644 --- a/libtmux-kotlin/build.gradle.kts +++ b/libtmux-kotlin/build.gradle.kts @@ -270,6 +270,20 @@ val documentedKotlin = val unknown = given - fixtures.keys require(unknown.isEmpty()) { "$where line $line: Given names $unknown; a snippet may assume ${fixtures.keys}" } val declared = given.joinToString("") { " val $it = ${fixtures.getValue(it)}\n" } + val controlRefusal = if (directive.startsWith("control:")) { + """ + | val version = javaServer.version() + | if (!io.github.libtmux.internal.ControlAcquisition.supports(version)) { + | assertFailsWith { + | runBlocking { + |${body.joinToString("\n") { " $it" }} + | } + | } + | assertEquals(0, javaServer.clients().size) + | return + | } + """.trimMargin() + } else "" val file = generated.get().asFile.resolve("io/github/libtmux/docs/kotlin/s$found/Snippet.kt") file.parentFile.mkdirs() file.writeText( @@ -290,6 +304,7 @@ val documentedKotlin = appendLine("import io.github.libtmux.junit5.TmuxExtension") appendLine("import io.github.libtmux.junit5.TmuxSocketPath") appendLine("import kotlin.test.assertEquals") + appendLine("import kotlin.test.assertFailsWith") appendLine("import kotlinx.coroutines.runBlocking") appendLine("import org.junit.jupiter.api.Test") appendLine("import org.junit.jupiter.api.extension.ExtendWith") @@ -301,6 +316,7 @@ val documentedKotlin = | @Test | fun `$name`(javaServer: io.github.libtmux.Server, socketPath: TmuxSocketPath) { |$declared + |$controlRefusal | // runBlocking is inline, so a snippet's own declarations shadow the | // ones above rather than colliding with them, and a bare return still | // leaves the test. Every call in this module's own surface is diff --git a/libtmux-scala-cats/README.md b/libtmux-scala-cats/README.md index 5c16d62a0..fef8a0536 100644 --- a/libtmux-scala-cats/README.md +++ b/libtmux-scala-cats/README.md @@ -67,10 +67,11 @@ suite. It prints the current command of each active pane. ### [`Control.attach`][control]: observe a session rename -Register the subscription and start its reader before changing tmux state, and -match the typed [`Notification`][notification] before acting on it: +Managed control requires tmux 3.7+ and a carrier-owned pinned socket. Register +the subscription and start its reader before changing tmux state, and match the +typed [`Notification`][notification] before acting on it: - + ```scala import _root_.cats.effect.IO import _root_.cats.syntax.all._ diff --git a/libtmux-scala-ox/README.md b/libtmux-scala-ox/README.md index 3ee4775c3..d6215142a 100644 --- a/libtmux-scala-ox/README.md +++ b/libtmux-scala-ox/README.md @@ -18,8 +18,9 @@ concurrency on virtual threads.** [`Flows.liveView`][flows] and [`Flows.subscription`][flows] turn pushed events into a cold `Flow`: a live view of a server, and a control client's -subscription. The facade's handles work unchanged in an Ox `supervised` -scope. +subscription. Acquiring either requires tmux 3.7+ and a carrier-owned pinned +socket; ordinary direct-style operations keep the core 3.3a minimum. The +facade's handles work unchanged in an Ox `supervised` scope. `io.github.libtmux:libtmux-scala-ox_3` — on Maven Central, released with the Java artifacts. @@ -49,7 +50,7 @@ It emits the current view of a server when the flow starts, then every newer one, so a change published between reading a view and collecting the flow is not missed. Here `config` selects an existing tmux server: - + ```scala import _root_.ox.{supervised, useCloseableInScope} import io.github.libtmux.scaladsl.{config => _, *} diff --git a/libtmux-workspace-cli/README.md b/libtmux-workspace-cli/README.md index 108c99ff4..2a1699160 100644 --- a/libtmux-workspace-cli/README.md +++ b/libtmux-workspace-cli/README.md @@ -226,7 +226,11 @@ Then the agent can: line. A failure names one `code`, and the exit status is `0`, `1`, `2` or `130`. [Output][ref-output] has the full contract. - **Optional Python.** Only `shell`, tmuxp plugins and custom builders need - Python with tmuxp 1.74.0, named by `TMUX_WORKSPACE_PYTHON`. + Python with tmuxp 1.74.0, named by `TMUX_WORKSPACE_PYTHON`, and a Python + libtmux whose `TMUX_OPTIONAL_FEATURE_POLICY_VERSION` is 2 or more. No + released libtmux has that yet; it comes with + [tmux-python/libtmux#780](https://github.com/tmux-python/libtmux/pull/780), + so install libtmux from that source or skip those features. ## Documentation diff --git a/libtmux/README.md b/libtmux/README.md index eb092a558..545c9469e 100644 --- a/libtmux/README.md +++ b/libtmux/README.md @@ -340,6 +340,7 @@ where the buffer overflowed. Reference: [javadoc.io][javadoc-control] and [libtmux.org][controlclient]. + ```java // Given: Server server, Session session try (ControlClient client = server.control(session); From f00e74ae9a22f3ea50eac5d2ce7cbfd34441e2c3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 04:28:37 -0500 Subject: [PATCH 06/18] Migration(docs): Explain the optional tmux requirements why: Callers need to know which operations keep the 3.3a floor and which are refused earlier, and what a custom ControlCarrier must now provide. what: - Table of the per-feature requirement or repair, the escape-hatch boundary for nested command strings, shell programs and aliases, and the pinSocket contract for custom carriers. - The Python bridge requires optional-policy marker 2, which no released Python libtmux declares yet; say so and give the remedy. --- MIGRATION.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 9 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 9567f72a6..99a721d4c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -34,12 +34,13 @@ token that names no release, such as `master`, fails with `MalformedResponseException`. A candidate for 3.3a does not satisfy the released 3.3a minimum. -Ordinary command requests carry an atomic version guard. Control-client -startup checks the floor through its process carrier before launching `-C`; -it does not pin the endpoint between that check and attachment. These checks -require native built-in command semantics. A tmux configuration that replaces -commands such as `if-shell` or `run-shell` through `command-alias` is outside -this qualification. +Ordinary command requests carry an atomic version guard. Managed control +acquisition requires tmux 3.7 or newer and pins the socket before `-C`. +The identity is checked through that pin before attaching; replacement of the +original pathname cannot redirect initialization to a new daemon. +These checks require native built-in command semantics. A tmux configuration +that replaces commands such as `if-shell` or `run-shell` through +`command-alias` is outside this qualification. The first session on an unused endpoint checks the configured binary before starting a daemon. Reads leave an unused endpoint empty; cleanup of an absent @@ -57,9 +58,67 @@ before starting Python. This validates the selected daemon or executable without starting a daemon; later Java operations still run their own version check. The bridge also requires Python libtmux whose declared `TMUX_MIN_VERSION` is 3.3a or newer and whose own dispatcher enforces that -floor on each command. An older installed backend is refused before builders, -plugins or shell code run, and upgrading tmuxp alone does not supply a -command-level version check. +floor on each command, and the integer +`libtmux.common.TMUX_OPTIONAL_FEATURE_POLICY_VERSION >= 2`. It checks both +requirements in the runtime probe and again in the actual child, before +builders, plugins or shell code run. Policy version 1 is refused, and +upgrading tmuxp alone does not supply a command-level version check or the +optional guards. + +No libtmux release on PyPI declares that marker yet; it arrives with +[tmux-python/libtmux#780](https://github.com/tmux-python/libtmux/pull/780). +Until it ships, `tmux-workspace shell` and extension loading refuse on every +installable Python libtmux. Either install libtmux from that source into the +interpreter named by `TMUX_WORKSPACE_PYTHON`, or leave `shell`, `plugins` and +custom builders alone: native loading and every read command need no Python. + +### Optional tmux requirements + +Ordinary subprocess operations, polling, MCP tools and native workspace +building keep the 3.3a core floor. The selected daemon determines eligibility, +even when the configured client is newer. A refused literal batch runs none +of its commands, including a preceding command that would otherwise succeed. + +| Operation | Requirement or repair | +| --- | --- | +| Managed control, watches, mirrors and live views | Stable tmux 3.7+ and a carrier-owned pinned socket. Older daemons are refused before control initialization. | +| Exact named buffer deletion and rename | tmux 3.4+. On 3.3a, a missing name can select an unrelated buffer. | +| `Pane.paste(text)` | tmux 3.4+ before creating a temporary buffer, because failure cleanup needs exact named deletion. | +| Direct `paste-buffer -d -b name` | Retained on 3.3a; native lookup already rejects a missing name. | +| Literal `select-layout` on 3.3a | Invalid operands are refused before every batch effect. Presets, unique prefixes and serialized layouts remain available; `-o` requires a serialized operand. | +| Percentage splitting | Typed `SplitSpec.percent` retains `-l N%`. Literal `split-window -p` is refused on exact 3.4. | +| `break-pane` on exact 3.7 | Literal calls and unnamed typed calls are refused. Use `Pane.breakOut(name)` or upgrade to 3.7a+. | +| Single-pane `breakOut` before 3.6 | The typed call reads the pane's actual post-command context; native success does not print `-P` output there. | +| Captured `run-shell` output | Existing 3.5 floor remains; 3.3a and 3.4 lose the requested output. Effect-only shell commands remain available. | + +Named `breakOut` on exact 3.7 applies the name after the pane moves, because +that release ignores `-n` in its multi-pane branch. A later rename failure can +leave the pane in its new window. An unnamed single-pane break on other +supported releases preserves the existing window name. + +Custom `ControlCarrier` implementations must implement `pinSocket` in their +own execution realm. Its alias must retain the socket inode in a private +directory for the attachment's lifetime; replacing the original pathname must +not redirect it. The default method refuses control acquisition. Ordinary +commands through an existing custom transport are unaffected. The built-in +local carrier uses a short private hard link on the socket's filesystem and +refuses before `-C` if no valid alias can be created. Closing an attachment or +receiving EOF removes only its owned alias and directory; the carrier remains +borrowed. One timeout budget covers qualification and attachment. + +Literal command words retain their existing encoding, including semicolons +used as data. Feature guards inspect the literal commands a batch represents. +They do not check a tmux command string nested inside one: an `if-shell` body, +a `run-shell -C` command, a hook, a brace block or a `source-file` file. They +also do not interpret shell text or configured aliases. Do not nest a command +this library refuses. The guard does not look inside it, so tmux 3.3a or 3.7 +can still end the server or act on the wrong buffer. Those nested strings keep +native tmux semantics and are outside the managed optional-feature guarantee. + +Control-positive matrix cases run from 3.7. Lower lanes keep ordinary +operations and explicit before-effect refusal assertions. Documentation +control examples compile on every lane and assert the refusal below that +feature's floor. ### Version ordering and parsing From 87464a3cb6760ac5101f81a21fa30b0db44483f2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 05:00:06 -0500 Subject: [PATCH 07/18] Control(fix): Read the control identity without firing hooks why: Control acquisition read the daemon identity with `display-message -p`, and the attach then confirmed it with another display-message over the control line, as did Server.control before it. Each fires a user's after-display-message hook, the first two before the daemon was known to be eligible. run-shell -C cannot answer over a control line: its parse error is printed outside any %begin/%end block, so the block ends empty. what: - The identity is read by subprocess with `run-shell -C`, as the version probe is. tmux names only the first word it cannot parse, so the fields travel in one word: version, pid and start time joined by commas, then the socket path in single quotes with embedded quotes closed, escaped and reopened. More or fewer lines than one are refused. - After the attach the identity is read again through the pin, by subprocess, and a mismatch closes the client; nothing is read over the control line. - Server.control no longer reads `#{pid} #{version}`: the pinned acquisition compares the daemon it reaches with the captured pid, start time and version. The floor is decided with the hook-free probe when the capture carries no version. - Cases: a socket path with a space, a quote and a comma survives; the hook stays quiet through qualification and recheck while a control `display-message` fires it. Verified on tmux 3.7 and master (next-3.9). Reintroducing a display-message read in the recheck fails the hook case on 3.7 ("acquisition ran the hook"). Control replies are framed with display-message markers once a client is attached; that is the client's own traffic and unchanged. --- .../it/ControlAcquisitionIntegrationTest.java | 56 +++++++++++++++++++ .../io/github/libtmux/junit5/FakeTmux.java | 16 +++--- .../libtmux/kotlin/FakeControlClients.kt | 13 ++--- .../scaladsl/cats/ObservationSuite.scala | 12 ++-- .../main/java/io/github/libtmux/Server.java | 24 ++------ .../github/libtmux/control/ControlClient.java | 40 +------------ .../libtmux/internal/ControlAcquisition.java | 32 +++++++++-- .../java/io/github/libtmux/ServerTest.java | 2 +- .../libtmux/control/ControlClientTest.java | 13 ++--- 9 files changed, 114 insertions(+), 94 deletions(-) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ControlAcquisitionIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ControlAcquisitionIntegrationTest.java index 96f05b579..8d8b8ba39 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/ControlAcquisitionIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/ControlAcquisitionIntegrationTest.java @@ -167,4 +167,60 @@ void aValidLongSocketCanUseAShortAlias(Server fixture) throws Exception { Files.deleteIfExists(root); } } + + /** The identity travels in one word, so a socket path with a space and a quote must survive it. */ + @Test + void anIdentityKeepsSpacesAndQuotesInTheSocketPath(Server fixture) throws Exception { + Path root = Files.createTempDirectory(Path.of("/tmp/libtmux-java-test"), "quoted-"); + Path directory = Files.createDirectory(root.resolve("it's a dir")); + Path socket = directory.resolve("a socket, with comma"); + ServerConfig config = ServerConfig.builder() + .binary(fixture.config().binary()) + .endpoint(ServerEndpoint.socketPath(socket)) + .configFile(Path.of("/dev/null")) + .build(); + try (Server server = Server.open(config)) { + try { + server.newSession("quoted-socket"); + try (ProcessTransport processes = new ProcessTransport(); + ControlAcquisition acquisition = ControlAcquisition.acquire( + processes.controlCarrier().orElseThrow(), config, Duration.ofSeconds(30))) { + assertEquals(socket.toString(), acquisition.identity().socket()); + } + } finally { + server.killServer(); + } + } finally { + Files.deleteIfExists(socket); + Files.deleteIfExists(directory); + Files.deleteIfExists(root); + } + } + + /** + * Qualifying a daemon for control reads its version and identity, rechecks both through the + * pin, and a user's {@code after-display-message} hook must not run for any of it. (Control + * replies are framed with {@code display-message} markers once a client is attached; that is + * the client's own traffic and is not part of acquisition.) The last step fires the hook the + * ordinary way, so a quiet record means the hook was live and not run. + */ + @Test + void qualifyingTheDaemonRunsNoAfterDisplayMessageHook(Server server) throws Exception { + assertTrue(server.cmd("set-hook", "-g", "after-display-message", "set-option -g @acquire-hook fired") + .succeeded()); + try (ProcessTransport processes = new ProcessTransport(); + ControlAcquisition acquisition = ControlAcquisition.acquire( + processes.controlCarrier().orElseThrow(), server.config(), Duration.ofSeconds(30))) { + acquisition.recheck(processes.controlCarrier().orElseThrow()); + assertTrue(acquisition.identity().pid() > 0); + } + + assertEquals( + List.of(), server.cmd("show-options", "-gqv", "@acquire-hook").stdout(), "acquisition ran the hook"); + assertTrue(server.cmd("display-message", "-p", "control").succeeded()); + assertEquals( + List.of("fired"), + server.cmd("show-options", "-gqv", "@acquire-hook").stdout(), + "the hook never fires, so the check above proves nothing"); + } } diff --git a/libtmux-junit5/src/main/java/io/github/libtmux/junit5/FakeTmux.java b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/FakeTmux.java index b101dd60f..d6cb8b433 100644 --- a/libtmux-junit5/src/main/java/io/github/libtmux/junit5/FakeTmux.java +++ b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/FakeTmux.java @@ -270,11 +270,13 @@ public Process start(List command) throws IOException { return VersionProbe.refusing(refusal(command.getLast())); } if (command.size() >= 3 - && command.subList(command.size() - 3, command.size() - 1) - .equals(List.of("display-message", "-p")) - && command.getLast().equals("#{version}\t#{pid}\t#{start_time}\t#{socket_path}")) { - return VersionProbe.reporting( - VERSION + "\t" + pid + "\t" + started + "\t/tmp/libtmux-java-fake/" + pid); + && command.get(command.size() - 3).equals("run-shell") + && command.get(command.size() - 2).equals("-C") + && command.getLast().startsWith("libtmux-identity-")) { + String format = command.getLast(); + String marker = format.substring(0, format.indexOf("#{version}")); + return VersionProbe.refusing("unknown command: " + marker + VERSION + "," + pid + "," + started + + ",/tmp/libtmux-java-fake/" + pid); } if (command.size() >= 4 && command.subList(command.size() - 4, command.size() - 1) @@ -334,10 +336,6 @@ private VersionProbe(int exit, String stdout, String stderr) { error = new ByteArrayInputStream((stderr.isEmpty() ? "" : stderr + "\n").getBytes(StandardCharsets.UTF_8)); } - static VersionProbe reporting(String report) { - return new VersionProbe(0, report, ""); - } - static VersionProbe refusing(String stderr) { return new VersionProbe(1, "", stderr); } diff --git a/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/FakeControlClients.kt b/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/FakeControlClients.kt index cc820f36e..6e27ba86e 100644 --- a/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/FakeControlClients.kt +++ b/libtmux-kotlin/src/test/kotlin/io/github/libtmux/kotlin/FakeControlClients.kt @@ -61,17 +61,16 @@ internal object FakeControlClients { printf 'unknown command: %s3.7c\n' "${'$'}{argument%'#{version}'}" >&2 exit 1;; esac - if [ "${'$'}argument" = '#{version} #{pid} #{start_time} #{socket_path}' ]; then - printf '3.7c\t4242\t10\t/fake-control\n'; exit - fi + case "${'$'}argument" in + libtmux-identity-*) + printf 'unknown command: %s3.7c,4242,10,/fake-control\n' "${'$'}{argument%%'#{version}'*}" >&2 + exit 1;; + esac done printf '%%begin 100 1 0\n%%end 100 1 0\n' read_request() { while IFS= read -r request && IFS= read -r marker; do - case "${'$'}request" in - *'#{start_time} #{pid} #{version}'*) answer '10 4242 3.7c';; - *) return 0;; - esac + return 0 done return 1 } diff --git a/libtmux-scala-cats/src/test/scala/io/github/libtmux/scaladsl/cats/ObservationSuite.scala b/libtmux-scala-cats/src/test/scala/io/github/libtmux/scaladsl/cats/ObservationSuite.scala index 520cc860e..8d52abc93 100644 --- a/libtmux-scala-cats/src/test/scala/io/github/libtmux/scaladsl/cats/ObservationSuite.scala +++ b/libtmux-scala-cats/src/test/scala/io/github/libtmux/scaladsl/cats/ObservationSuite.scala @@ -269,16 +269,14 @@ object ObservationSuite { | libtmux-version-*'#{version}') | printf 'unknown command: %s3.7c\n' "${argument%'#{version}'}" >&2; exit 1;; | esac - | if [ "$argument" = '#{version} #{pid} #{start_time} #{socket_path}' ]; then - | printf '3.7c\t4242\t10\t/fake-control\n'; exit - | fi + | case "$argument" in + | libtmux-identity-*) + | printf 'unknown command: %s3.7c,4242,10,/fake-control\n' "${argument%%'#{version}'*}" >&2; exit 1;; + | esac |done |read_request() { | while IFS= read -r request && IFS= read -r marker; do - | case "$request" in - | *'#{start_time} #{pid} #{version}'*) answer '10 4242 3.7c';; - | *) return 0;; - | esac + | return 0 | done | return 1 |} diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index cb31ae827..8a52461f7 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -952,32 +952,16 @@ public ControlClient control(Session session, Duration timeout) { requireOpen(); ServerSnapshot captured = session.snapshot(); // Before the identity read below, so an unsupported daemon is refused without running anything on it. - io.github.libtmux.internal.ControlAcquisition.requireSupported(version(captured)); + // The hook-free probe, not version(captured): an unversioned capture would read it with display-message. + TmuxVersion daemon = captured.serverVersion().orElseGet(this::versionForCreation); + io.github.libtmux.internal.ControlAcquisition.requireSupported(daemon); long pid = captured.serverPid() .orElseThrow(() -> new IllegalStateException("a live handle has no server process identity")); - CommandResult live = fence.guarded(captured, "display-message -p '#{pid} #{version}'", ""); - if (!live.succeeded() || live.stdout().size() != 1) { - throw failed("display-message", live); - } - String reported = live.stdout().get(0); - int space = reported.indexOf(' '); - if (space < 1) { - throw new MalformedResponseException("tmux reported a malformed server identity"); - } - long seen; - try { - seen = Long.parseLong(reported.substring(0, space)); - } catch (NumberFormatException e) { - throw new MalformedResponseException("tmux reported a malformed server pid"); - } - if (seen != pid) { - throw new TargetGoneException("the tmux server this handle belonged to has ended"); - } ControlCarrier carrier = transport .controlCarrier() .orElseThrow(() -> new IllegalStateException("this transport does not start control clients")); return ControlClient.attach( - carrier, config, session.id(), pid, captured.serverStartTime(), reported.substring(space + 1), timeout); + carrier, config, session.id(), pid, captured.serverStartTime(), daemon.toString(), timeout); } /** 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 6110a0dd1..7a71d0d1b 100644 --- a/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java +++ b/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java @@ -11,8 +11,6 @@ import io.github.libtmux.exception.ControlEndedException; import io.github.libtmux.exception.DispatchException; import io.github.libtmux.exception.LibTmuxException; -import io.github.libtmux.exception.MalformedResponseException; -import io.github.libtmux.exception.TargetGoneException; import io.github.libtmux.internal.ControlAcquisition; import io.github.libtmux.internal.ErrorText; import io.github.libtmux.internal.LocalSocketPin; @@ -250,12 +248,9 @@ private static ControlClient connect( reply.lines()); } client.writer.start(); - var identity = acquisition.identity(); - client.confirmIncarnation( - identity.pid(), - java.util.OptionalLong.of(identity.start()), - identity.version(), - acquisition.remaining(true)); + // Not over the control line: run-shell -C reports through stderr, which a control reply + // does not carry, and display-message there would run the user's hooks. + acquisition.recheck(carrier); client.requestJsonLayouts(acquisition.remaining(true)); client.finishAttach(session); return client; @@ -273,35 +268,6 @@ private static ControlClient connect( } } - /** Reads the live server and detaches, via the caller, when it is not the one that was named. */ - private void confirmIncarnation( - long expectedPid, java.util.OptionalLong expectedStart, String expectedVersion, Duration timeout) { - // The start time leads: it is a number, and the version, compared as text, may hold a space. - ControlReply reply = send(List.of("display-message", "-p", "#{start_time} #{pid} #{version}"), timeout); - if (!reply.succeeded() || reply.lines().size() != 1) { - throw new CommandRejectedException( - "could not read the control client's server" + ErrorText.suffix(reply.lines()), - "display-message", - -1, - reply.lines()); - } - String[] reported = reply.lines().get(0).split(" ", 3); - long started; - long pid; - try { - started = Long.parseLong(reported[0]); - pid = Long.parseLong(reported.length > 1 ? reported[1] : ""); - } catch (NumberFormatException e) { - throw new MalformedResponseException("tmux reported a malformed server identity"); - } - String version = reported.length > 2 ? reported[2] : ""; - if (pid != expectedPid - || !version.equals(expectedVersion) - || (expectedStart.isPresent() && started != expectedStart.getAsLong())) { - throw new TargetGoneException("the tmux server this handle belonged to has ended"); - } - } - private void finishAttach(SessionId session) { LOG.log(System.Logger.Level.DEBUG, "tmux control client attached to session {0}", session.value()); } diff --git a/libtmux/src/main/java/io/github/libtmux/internal/ControlAcquisition.java b/libtmux/src/main/java/io/github/libtmux/internal/ControlAcquisition.java index ab60c7f1d..3b7f47231 100644 --- a/libtmux/src/main/java/io/github/libtmux/internal/ControlAcquisition.java +++ b/libtmux/src/main/java/io/github/libtmux/internal/ControlAcquisition.java @@ -18,11 +18,12 @@ import java.util.ArrayList; import java.util.List; import java.util.OptionalLong; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicBoolean; /** Qualifies a carrier-owned endpoint before the first control effect. */ public final class ControlAcquisition implements AutoCloseable { - private static final String FORMAT = "#{version}\t#{pid}\t#{start_time}\t#{socket_path}"; + private static final String FORMAT = "#{version},#{pid},#{start_time},'#{s/'/'\\\\''/:socket_path}'"; private static final TmuxVersion MINIMUM = new TmuxVersion(3, 7, ""); private final ControlCarrier.PinnedSocket pin; private final Identity identity; @@ -103,14 +104,24 @@ public static void requireSupported(TmuxVersion version) { } } + /** + * Reads the daemon identity without firing a user's hooks. + * + *

As the version probe does, this uses {@code run-shell -C}: tmux parses the expansion as a + * command and names the first word it cannot parse, so every field travels in one word. The + * fields are joined with commas, and the socket path comes last inside single quotes with its own + * quotes closed, escaped and reopened, so spaces and quotes in it survive the parse. + */ private static Identity read(ControlCarrier carrier, List command, Duration timeout) { List endpoint = new ArrayList<>(command); endpoint.add(1, "-N"); + String marker = "libtmux-identity-" + + Long.toHexString(ThreadLocalRandom.current().nextLong()) + "-"; CommandResult result; try { result = ProcessTransport.executeThroughCarrier( carrier, - new CommandRequest(endpoint, List.of(List.of("display-message", "-p", FORMAT)), timeout, "")); + new CommandRequest(endpoint, List.of(List.of("run-shell", "-C", marker + FORMAT)), timeout, "")); } catch (DispatchException failure) { DispatchException denied = failure instanceof DispatchException.TimedOut ? new DispatchException.TimedOut( @@ -119,15 +130,17 @@ private static Identity read(ControlCarrier carrier, List command, Durat "control identity probe failed", DispatchOutcome.NOT_DISPATCHED, failure); throw denied; } - if (!result.succeeded()) { + String prefix = "unknown command: " + marker; + List answers = + result.stderr().stream().filter(line -> line.startsWith(prefix)).toList(); + if (result.succeeded() || answers.size() != 1 || result.stderr().size() != 1) { throw new CommandRejectedException( "cannot query the control endpoint" + ErrorText.suffix(result.stderr()), - "display-message", + "run-shell", result.exitCode(), result.stderr()); } - if (result.stdout().size() != 1) throw new MalformedResponseException("tmux reported no control identity"); - String[] fields = result.stdout().getFirst().split("\t", 4); + String[] fields = answers.getFirst().substring(prefix.length()).split(",", 4); try { if (fields.length != 4 || fields[3].isEmpty()) throw new NumberFormatException(); long pid = Long.parseLong(fields[1]); @@ -139,6 +152,13 @@ private static Identity read(ControlCarrier carrier, List command, Durat } } + /** Rereads the identity through the pin and refuses a daemon that is no longer the qualified one. */ + public void recheck(ControlCarrier carrier) { + if (!identity.equals(read(carrier, command, remaining(true)))) { + throw new TargetGoneException("the tmux endpoint changed during control acquisition"); + } + } + public List command() { return command; } diff --git a/libtmux/src/test/java/io/github/libtmux/ServerTest.java b/libtmux/src/test/java/io/github/libtmux/ServerTest.java index 04af7407c..3e706f6ba 100644 --- a/libtmux/src/test/java/io/github/libtmux/ServerTest.java +++ b/libtmux/src/test/java/io/github/libtmux/ServerTest.java @@ -785,7 +785,7 @@ public CommandResult execute(CommandRequest request) { 0, List.of(String.join( separator, "$0", "@0", "0", "%0", "0", "1", "zsh", "80", "24", "0", "0", "", - "/tmp", "", "0", "0", "0", "0")), + "/tmp", "", "0", "0", "0", "0", "0")), List.of()); default -> new CommandResult(0, List.of(), List.of()); }); 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 742ca5871..9d9a03a21 100644 --- a/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java +++ b/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java @@ -507,16 +507,15 @@ void extendedOutputIsOutput(@TempDir Path directory) throws Exception { printf 'unknown command: %s3.7d\\n' "${argument%'#{version}'}" >&2 exit 1;; esac - if [ "$argument" = '#{version}\t#{pid}\t#{start_time}\t#{socket_path}' ]; then - printf '3.7d\t4242\t10\t/fake-control\n'; exit - fi + case "$argument" in + libtmux-identity-*) + printf 'unknown command: %s3.7d,4242,10,/fake-control\\n' "${argument%%'#{version}'*}" >&2 + exit 1;; + esac done read_request() { while IFS= read -r request && IFS= read -r marker; do - case "$request" in - *'#{start_time} #{pid} #{version}'*) answer '10 4242 3.7d';; - *) return 0;; - esac + return 0 done return 1 } From cc7cee871a2804a96299c9d33492b400809693c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 08:08:25 -0500 Subject: [PATCH 08/18] Modules(test): Let the staged Ox consumer accept the control refusal why: The release rehearsal runs a staged consumer that reads a session's live view. A live view is managed control, which a daemon older than 3.7 now refuses before attaching, and the runner's tmux is 3.4, so the rehearsal failed with UnsupportedFeatureException. what: - The consumer treats UnsupportedFeatureException from the attach as the expected answer on an older daemon and still asserts one view where control is available. Verified by staging the release locally and running the consumer with tmux 3.7d (one view read) and tmux 3.4 on PATH (refusal accepted). --- .../scala/io/github/libtmux/consumer/OxConsumer.scala | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/module-tests/scala/cats/src/main/scala/io/github/libtmux/consumer/OxConsumer.scala b/module-tests/scala/cats/src/main/scala/io/github/libtmux/consumer/OxConsumer.scala index 065f8a299..16ba05676 100644 --- a/module-tests/scala/cats/src/main/scala/io/github/libtmux/consumer/OxConsumer.scala +++ b/module-tests/scala/cats/src/main/scala/io/github/libtmux/consumer/OxConsumer.scala @@ -4,7 +4,8 @@ import io.github.libtmux.ServerConfig import io.github.libtmux.scaladsl.* import io.github.libtmux.scaladsl.live.LiveView import io.github.libtmux.scaladsl.ox.Flows -import scala.util.Using +import io.github.libtmux.exception.UnsupportedFeatureException +import scala.util.{Failure, Success, Try, Using} /** Ox is direct-style: a session's live view as a `Flow`, read once. Its own * file, because the direct-style and Cats facades name their extensions alike. @@ -13,10 +14,14 @@ object OxConsumer { def readOneView(config: ServerConfig, name: String): Unit = Using.resource(Server.open(config)) { server => val session = server.session(name).getOrElse(throw new AssertionError(s"no session $name")) - val views = Using.resource(LiveView.attach(session)) { live => + // A live view is managed control, which tmux older than 3.7 gets refused before it attaches. + Try(Using.resource(LiveView.attach(session)) { live => Flows.liveView(live).take(1).runToList() + }) match { + case Success(views) => assert(views.size == 1) + case Failure(_: UnsupportedFeatureException) => () + case Failure(other) => throw other } - assert(views.size == 1) server.asJava.killServer() } } From 6a90e5d3e6c5bfac6c1d4d7c1c3051abdce7e05f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 09:17:59 -0500 Subject: [PATCH 09/18] Names(fix): Refuse a delimiter in a name on exact 3.7 before dispatch why: Exactly tmux 3.7 rejects `.` and `:` in session and window names. The library passed the name through, so a batch or a workspace could run the commands ahead of the rejected one and leave a half-built session. what: - Names.require, for a version or for a server (read hook-free), refuses such a name on exactly 3.7 (a candidate counts, 3.7a and later do not) with IllegalArgumentException. - TmuxFeatures applies it to new-session -s/-n, new-window -n and the new name of rename-session and rename-window, so typed calls, raw commands and batches are all covered, including clustered flags. - The workspace applier checks every window name before it creates the staging session, as it already checks layouts. - Cases: NamesTest over every release family; on the real 3.7 lane a batch whose second command carries the name leaves the first command's option unset and creates no window, while other releases accept it. Verified: making Names.require accept every name fails NamesTest and the 3.7 lane case. --- .../it/NameValidationIntegrationTest.java | 6 +-- .../it/OptionalFeatureIntegrationTest.java | 22 ++++++++++ .../libtmux/it/PushdownIntegrationTest.java | 8 ++-- .../libtmux/mcp/ToolsAgainstTmuxTest.java | 2 +- .../libtmux/workspace/WorkspaceApplier.java | 1 + .../main/java/io/github/libtmux/Names.java | 44 +++++++++++++++++++ .../github/libtmux/internal/TmuxFeatures.java | 37 ++++++++++++++++ .../java/io/github/libtmux/NamesTest.java | 26 +++++++++++ 8 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 libtmux/src/main/java/io/github/libtmux/Names.java create mode 100644 libtmux/src/test/java/io/github/libtmux/NamesTest.java diff --git a/integration-tests/src/test/java/io/github/libtmux/it/NameValidationIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/NameValidationIntegrationTest.java index 5d8efb957..c89e39338 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/NameValidationIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/NameValidationIntegrationTest.java @@ -29,13 +29,13 @@ private static boolean refuses(Server server) { } /** - * Measured on every supported build: rewritten to {@code a_b} through 3.6, refused on 3.7, kept + * Measured on every supported build: rewritten to {@code a_b} through 3.6, refused on 3.7 before dispatch, kept * as written from 3.7a. A caller who needs one answer has to keep delimiters out of the name. */ @Test void aDelimiterInASessionNameIsRewrittenRefusedOrKeptDependingOnTheTmux(Server server) { if (refuses(server)) { - assertThrows(LibTmuxException.class, () -> server.newSession("a:b")); + assertThrows(IllegalArgumentException.class, () -> server.newSession("a:b")); return; } @@ -109,7 +109,7 @@ void aDelimiterInAWindowNameIsKeptEverywhereExceptTheOneReleaseThatRefusesIt(Ser Window window = server.sessions().get(0).windows().get(0); if (refuses(server)) { - assertThrows(LibTmuxException.class, () -> window.rename("w:x")); + assertThrows(IllegalArgumentException.class, () -> window.rename("w:x")); return; } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java index 70b247c19..8b0f04af0 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java @@ -180,4 +180,26 @@ void percentageSplitsUseTheWorkingTypedFormOnExact34(Server server) { .succeeded()); assertFalse(pane.split(spec -> spec.percent(30)).id().equals(pane.id())); } + + @Test + void exactly37RefusesADelimiterInANameBeforeAnyEarlierCommandRuns(Server server) { + String name = "split.name"; + if (server.version().sameRelease(new TmuxVersion(3, 7, ""))) { + assertThrows( + IllegalArgumentException.class, + () -> server.batch() + .add("set-option", "-g", "@name-effect", "changed") + .add("new-window", "-d", "-n", name) + .run()); + assertThrows(IllegalArgumentException.class, () -> server.cmd("rename-session", "colon:name")); + assertThrows(IllegalArgumentException.class, () -> server.cmd("new-session", "-d", "-s", name)); + assertThrows(IllegalArgumentException.class, () -> server.cmd("new-window", "-dn" + name)); + assertEquals( + List.of(), + server.cmd("show-options", "-gqv", "@name-effect").stdout()); + assertEquals(1, server.windows().size()); + } else { + assertTrue(server.cmd("new-window", "-d", "-n", name).succeeded()); + } + } } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/PushdownIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/PushdownIntegrationTest.java index 0badc7915..cd9a2add1 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/PushdownIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/PushdownIntegrationTest.java @@ -12,7 +12,6 @@ import io.github.libtmux.Window; import io.github.libtmux.WindowSpec; import io.github.libtmux.Window_; -import io.github.libtmux.exception.LibTmuxException; import io.github.libtmux.junit5.TmuxExtension; import io.github.libtmux.query.FilterExpr; import io.github.libtmux.query.TmuxFilters; @@ -44,9 +43,10 @@ void aPushedFilterSelectsWhatTheLocalFilterSelects(Server server) { for (String name : NAMES) { try { server.newSession(name); - } catch (LibTmuxException refused) { - // 3.7 rejects . and : in a name; earlier releases store them as _. - assertTrue(String.valueOf(refused.getMessage()).contains("invalid session name"), refused::toString); + } catch (IllegalArgumentException refused) { + // 3.7 rejects . and : in a name, and the library says so before dispatch; earlier + // releases store them as _. + assertTrue(String.valueOf(refused.getMessage()).contains("tmux 3.7 refuses"), refused::toString); continue; } accepted.add(name); 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 7c567cb5e..9c5b134c5 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 @@ -349,7 +349,7 @@ void renamingReportsWhatTmuxSettledOnRatherThanWhatWasAsked(Server server) { server.version().atLeast(REJECTS_DELIMITER) && !server.version().atLeast(ACCEPTS_DELIMITER_AGAIN); if (refuses) { assertThrows( - LibTmuxException.class, + IllegalArgumentException.class, () -> Operations.renameSession( TestCalls.on(server, "session_id", session.id().value(), "new_name", "a.b"))); return; 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 cf5fc58fc..bcc641a5b 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 @@ -44,6 +44,7 @@ static Session build(Server server, Workspace workspace) { private static void validate(Server server, Workspace workspace) { for (WindowSpec window : workspace.windows()) { + io.github.libtmux.Names.require(window.name(), server); window.layout() .ifPresent(value -> Layouts.require(value, server, window.panes().size())); diff --git a/libtmux/src/main/java/io/github/libtmux/Names.java b/libtmux/src/main/java/io/github/libtmux/Names.java new file mode 100644 index 000000000..cb16ec387 --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/Names.java @@ -0,0 +1,44 @@ +package io.github.libtmux; + +import java.util.Objects; + +/** + * What the running tmux does with a session or window name. + * + *

Exactly tmux 3.7 refuses a name containing {@code .} or {@code :}; 3.7a and later keep it and + * earlier releases store {@code _} in its place. Refusing it here, before dispatch, keeps a batch or + * a workspace build from running the commands ahead of the one tmux would reject. + */ +public final class Names { + + private static final TmuxVersion REFUSES = new TmuxVersion(3, 7, ""); + + private Names() {} + + /** + * As {@link #require(String, TmuxVersion)}, for the daemon this server talks to, read without + * running a command that fires a user's hooks. + * + * @throws IllegalArgumentException if the daemon refuses a name with {@code .} or {@code :} + */ + public static String require(String name, Server server) { + Objects.requireNonNull(name, "name"); + return name.indexOf('.') < 0 && name.indexOf(':') < 0 ? name : require(name, server.versionForCreation()); + } + + /** + * Returns the name when the running tmux accepts it. + * + * @param name the session or window name to be sent + * @param running the version of the daemon that would receive it + * @throws IllegalArgumentException if the running tmux refuses a name with {@code .} or {@code :} + */ + public static String require(String name, TmuxVersion running) { + Objects.requireNonNull(name, "name"); + if (running.sameRelease(REFUSES) && (name.indexOf('.') >= 0 || name.indexOf(':') >= 0)) { + throw new IllegalArgumentException( + "tmux 3.7 refuses '.' and ':' in session and window names (3.7a and later keep them): " + name); + } + return name; + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java b/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java index abcb224e2..fd7ed7940 100644 --- a/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java +++ b/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java @@ -1,6 +1,7 @@ package io.github.libtmux.internal; import io.github.libtmux.Layouts; +import io.github.libtmux.Names; import io.github.libtmux.TmuxVersion; import io.github.libtmux.exception.UnsupportedFeatureException; import java.util.HashSet; @@ -56,6 +57,12 @@ && flags(command, "ceFlpsStRmxyXYL").contains('p')) { && !intent.repairedBreak()) { throw new UnsupportedFeatureException( "literal break-pane and unnamed breaks are unreliable on tmux 3.7; use Pane.breakOut(name) or tmux 3.7a+"); + } else if (matches(verb, "new-session", "new", 3)) { + names(version, command, "cefFnstxy", "ns"); + } else if (matches(verb, "new-window", "neww", 5)) { + names(version, command, "cenFt", "n"); + } else if (matches(verb, "rename-session", "rename", 8) || matches(verb, "rename-window", "renamew", 8)) { + renamed(version, command); } else if (matches(verb, "select-layout", "selectl", 8) && !version.atLeast(LAYOUT_ERROR_REPORTING)) { Set flags = new HashSet<>(); int operand = options(command, "t", flags); @@ -77,6 +84,36 @@ && flags(command, "ceFlpsStRmxyXYL").contains('p')) { } } + /** Refuses a name flag's value that the exact release would reject; {@code wanted} lists the flags. */ + private static void names(TmuxVersion version, List command, String valued, String wanted) { + int index = 1; + for (; index < command.size(); index++) { + String word = command.get(index); + if (word.equals("--") || !word.startsWith("-") || word.equals("-")) break; + for (int offset = 1; offset < word.length(); offset++) { + char flag = word.charAt(offset); + if (valued.indexOf(flag) < 0) continue; + String value; + if (offset + 1 < word.length()) { + value = word.substring(offset + 1); + } else if (index + 1 < command.size()) { + value = command.get(++index); + } else { + return; + } + if (wanted.indexOf(flag) >= 0) Names.require(value, version); + break; + } + } + } + + /** The new name is the operand after the options of rename-session and rename-window. */ + private static void renamed(TmuxVersion version, List command) { + Set ignored = new HashSet<>(); + int operand = options(command, "t", ignored); + if (operand < command.size()) Names.require(command.get(operand), version); + } + /** Whether the string leads with the four-digit checksum tmux computes over the rest. */ private static boolean checksummed(String layout) { if (layout.length() < 6 || layout.charAt(4) != ',') { diff --git a/libtmux/src/test/java/io/github/libtmux/NamesTest.java b/libtmux/src/test/java/io/github/libtmux/NamesTest.java new file mode 100644 index 000000000..a5d46ec9e --- /dev/null +++ b/libtmux/src/test/java/io/github/libtmux/NamesTest.java @@ -0,0 +1,26 @@ +package io.github.libtmux; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +final class NamesTest { + + @ParameterizedTest + @ValueSource(strings = {"1.0", "a:b", "x.", ":y"}) + void exactly37RefusesADelimiterButEveryOtherReleaseKeepsIt(String name) { + assertThrows(IllegalArgumentException.class, () -> Names.require(name, TmuxVersion.parse("3.7"))); + assertThrows(IllegalArgumentException.class, () -> Names.require(name, TmuxVersion.parse("3.7-rc2"))); + for (String other : new String[] {"3.3a", "3.6b", "3.7a", "3.7d", "3.8", "next-3.7", "next-3.9"}) { + assertEquals(name, Names.require(name, TmuxVersion.parse(other)), other); + } + } + + @ParameterizedTest + @ValueSource(strings = {"plain", "with space", "under_score"}) + void aPlainNameIsAlwaysAccepted(String name) { + assertEquals(name, Names.require(name, TmuxVersion.parse("3.7"))); + } +} From 7f494822a59e29df1c4d84bd7bbc04031addd6bc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 09:18:11 -0500 Subject: [PATCH 10/18] Migration(docs): List the exact-3.7 name rule why: Exact tmux 3.7 refuses a dot or colon in a session or window name, so a caller on that release needs to know before a batch is sent. what: - Add the delimiter rule for session and window names to the optional requirements table. --- MIGRATION.md | 1 + 1 file changed, 1 insertion(+) diff --git a/MIGRATION.md b/MIGRATION.md index 99a721d4c..620a7a918 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -87,6 +87,7 @@ of its commands, including a preceding command that would otherwise succeed. | Direct `paste-buffer -d -b name` | Retained on 3.3a; native lookup already rejects a missing name. | | Literal `select-layout` on 3.3a | Invalid operands are refused before every batch effect. Presets, unique prefixes and serialized layouts remain available; `-o` requires a serialized operand. | | Percentage splitting | Typed `SplitSpec.percent` retains `-l N%`. Literal `split-window -p` is refused on exact 3.4. | +| Session and window names on exact 3.7 | A name containing `.` or `:` is refused before dispatch (`IllegalArgumentException`), including workspace window names; 3.7a and later keep such names. | | `break-pane` on exact 3.7 | Literal calls and unnamed typed calls are refused. Use `Pane.breakOut(name)` or upgrade to 3.7a+. | | Single-pane `breakOut` before 3.6 | The typed call reads the pane's actual post-command context; native success does not print `-P` output there. | | Captured `run-shell` output | Existing 3.5 floor remains; 3.3a and 3.4 lose the requested output. Effect-only shell commands remain available. | From 1e6f338f73afc930ae0b3848b700c3f068ece8b3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 11:24:36 -0500 Subject: [PATCH 11/18] Examples(test): Gate watching examples on control why: The watching examples attach a control client, which a daemon older than 3.7 now refuses. The JDK checks run them against the runner's tmux 3.4 and failed (RunnableExamplesSuite, ExamplesRunTest, MainsTest). what: - Those cases assume ControlAcquisition.supports (Scala: the fixture's supportsControl), so they run on 3.7 and newer and are skipped below. Verified with :examples:test under tmux 3.4. --- .../io/github/libtmux/examples/ExamplesRunTest.java | 8 ++++++-- .../java/io/github/libtmux/examples/MainsTest.java | 11 ++++++++--- .../scaladsl/examples/RunnableExamplesSuite.scala | 3 +++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/examples/src/test/java/io/github/libtmux/examples/ExamplesRunTest.java b/examples/src/test/java/io/github/libtmux/examples/ExamplesRunTest.java index a43d1c0a9..da0746012 100644 --- a/examples/src/test/java/io/github/libtmux/examples/ExamplesRunTest.java +++ b/examples/src/test/java/io/github/libtmux/examples/ExamplesRunTest.java @@ -3,11 +3,13 @@ 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 static org.junit.jupiter.api.Assumptions.assumeTrue; import io.github.libtmux.Pane; import io.github.libtmux.Server; import io.github.libtmux.control.ControlEvent; import io.github.libtmux.control.PaneOutput; +import io.github.libtmux.internal.ControlAcquisition; import io.github.libtmux.junit5.TmuxExtension; import io.github.libtmux.junit5.TmuxSocketPath; import java.time.Duration; @@ -78,7 +80,8 @@ void aPrintedLineIsFoundBehindAModeSwitchButNotInTheTypedCommand() { } @Test - void watchingAPaneSeesWhatItPrints(TmuxSocketPath socket) { + void watchingAPaneSeesWhatItPrints(Server server, TmuxSocketPath socket) { + assumeTrue(ControlAcquisition.supports(server.version()), "managed control needs tmux 3.7 or newer"); List seen = WatchPaneOutput.run(socket.path(), Duration.ofSeconds(30), output -> {}); assertTrue(WatchPaneOutput.sawTheEcho(seen), "attaching is what makes tmux push output: " + seen); @@ -114,7 +117,8 @@ void servingOverMcpReportsTheFixedToolSurface(TmuxSocketPath socket) { } @Test - void watchingAServerIsToldWhenAWindowAppears(TmuxSocketPath socket) { + void watchingAServerIsToldWhenAWindowAppears(Server server, TmuxSocketPath socket) { + assumeTrue(ControlAcquisition.supports(server.version()), "managed control needs tmux 3.7 or newer"); List seen = WatchWhatChanges.run(socket.path(), Duration.ofSeconds(30), event -> {}); assertTrue( diff --git a/examples/src/test/java/io/github/libtmux/examples/MainsTest.java b/examples/src/test/java/io/github/libtmux/examples/MainsTest.java index 2fee3e139..55f0b99b6 100644 --- a/examples/src/test/java/io/github/libtmux/examples/MainsTest.java +++ b/examples/src/test/java/io/github/libtmux/examples/MainsTest.java @@ -6,8 +6,10 @@ import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import io.github.libtmux.Server; +import io.github.libtmux.internal.ControlAcquisition; import io.github.libtmux.junit5.TmuxExtension; import io.github.libtmux.junit5.TmuxSocketPath; import java.io.IOException; @@ -84,21 +86,24 @@ void serveTmuxOverMcp(TmuxSocketPath socket) throws Exception { } @Test - void watchPaneOutput(TmuxSocketPath socket) throws Exception { + void watchPaneOutput(Server server, TmuxSocketPath socket) throws Exception { + assumeTrue(ControlAcquisition.supports(server.version()), "managed control needs tmux 3.7 or newer"); String out = launch("WatchPaneOutput", socket.path().toString()); assertTrue(WatchPaneOutput.printedLine(out, "watched"), out); } @Test - void watchWhatChanges(TmuxSocketPath socket) throws Exception { + void watchWhatChanges(Server server, TmuxSocketPath socket) throws Exception { + assumeTrue(ControlAcquisition.supports(server.version()), "managed control needs tmux 3.7 or newer"); String out = launch("WatchWhatChanges", socket.path().toString()); assertTrue(out.contains("watched-into-existence"), out); } @Test - void watchWithFlow(TmuxSocketPath socket) throws Exception { + void watchWithFlow(Server server, TmuxSocketPath socket) throws Exception { + assumeTrue(ControlAcquisition.supports(server.version()), "managed control needs tmux 3.7 or newer"); String out = launch("WatchWithFlowKt", socket.path().toString()); assertEquals("echoed=true\ncancelled=true\n", out); diff --git a/examples/src/test/scala/io/github/libtmux/scaladsl/examples/RunnableExamplesSuite.scala b/examples/src/test/scala/io/github/libtmux/scaladsl/examples/RunnableExamplesSuite.scala index ef9e09dc1..9e9f417f9 100644 --- a/examples/src/test/scala/io/github/libtmux/scaladsl/examples/RunnableExamplesSuite.scala +++ b/examples/src/test/scala/io/github/libtmux/scaladsl/examples/RunnableExamplesSuite.scala @@ -57,6 +57,9 @@ final class RunnableExamplesSuite extends FunSuite { test("runs actual main: " + name.stripPrefix(packageName)) { assert(expectedMains(name), "unregistered executable example") OwnedTmux.use { fixture => + // Watching is managed control, which needs tmux 3.7 or newer. + if (Set("ObserveChanges", "WatchWithOx")(name.stripPrefix(packageName))) + assume(fixture.supportsControl, "managed control needs tmux 3.7+") val sessionsBefore = fixture.server.sessions().asScala.map(_.id()).toVector val clientsBefore = From 6affcaa8d6895873a52183566678f8c47929ca1d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 19 Oct 2026 19:06:18 -0500 Subject: [PATCH 12/18] Support(feat): Record capabilities in one registry and manifest why: The gates for optional tmux features kept their releases in separate constants, so the documentation could not say which release each one needs, and the constants could drift from anything published. One table that both the gates and a manifest read keeps what the library does and what the docs claim the same thing. what: - Add internal.support.Capabilities, the typed registry of capabilities and release workarounds, with the shared libtmux-capabilities/1 IDs. - Read the releases in TmuxFeatures, ControlAcquisition, Layouts, Layout, CaptureSpec, Names, Pane and Shell from the registry. - Emit the root capabilities.json from the registry, sorted and with a trailing newline. - Add CapabilitiesTest: the drift check against the committed manifest, unique kebab-case IDs, public API and error names, and each gate against its registry release. - Word the refused-layout message as a layout without tmux's checksum, so the appended requirement is not read as about serialized layouts. - Declare capabilities.json an input of the libtmux test task so an edit to it reruns the drift check instead of hitting the build cache. --- MIGRATION.md | 2 +- capabilities.json | 287 ++++++++++++ .../it/OptionalFeatureIntegrationTest.java | 6 +- libtmux/build.gradle.kts | 8 +- .../java/io/github/libtmux/CaptureSpec.java | 7 +- .../main/java/io/github/libtmux/Layout.java | 4 +- .../main/java/io/github/libtmux/Layouts.java | 16 +- .../main/java/io/github/libtmux/Names.java | 6 +- .../src/main/java/io/github/libtmux/Pane.java | 23 +- .../main/java/io/github/libtmux/Shell.java | 11 +- .../libtmux/internal/ControlAcquisition.java | 4 +- .../github/libtmux/internal/TmuxFeatures.java | 46 +- .../internal/support/Capabilities.java | 410 ++++++++++++++++++ .../libtmux/CaptureSpecRegistryTest.java | 38 ++ .../internal/support/CapabilitiesTest.java | 210 +++++++++ 15 files changed, 1020 insertions(+), 58 deletions(-) create mode 100644 capabilities.json create mode 100644 libtmux/src/main/java/io/github/libtmux/internal/support/Capabilities.java create mode 100644 libtmux/src/test/java/io/github/libtmux/CaptureSpecRegistryTest.java create mode 100644 libtmux/src/test/java/io/github/libtmux/internal/support/CapabilitiesTest.java diff --git a/MIGRATION.md b/MIGRATION.md index 620a7a918..6e36e35d5 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -85,7 +85,7 @@ of its commands, including a preceding command that would otherwise succeed. | Exact named buffer deletion and rename | tmux 3.4+. On 3.3a, a missing name can select an unrelated buffer. | | `Pane.paste(text)` | tmux 3.4+ before creating a temporary buffer, because failure cleanup needs exact named deletion. | | Direct `paste-buffer -d -b name` | Retained on 3.3a; native lookup already rejects a missing name. | -| Literal `select-layout` on 3.3a | Invalid operands are refused before every batch effect. Presets, unique prefixes and serialized layouts remain available; `-o` requires a serialized operand. | +| Literal `select-layout` on 3.3a | Invalid operands are refused with `UnsupportedFeatureException` before every batch effect. Presets, unique prefixes and serialized layouts remain available; `-o` requires a serialized operand. | | Percentage splitting | Typed `SplitSpec.percent` retains `-l N%`. Literal `split-window -p` is refused on exact 3.4. | | Session and window names on exact 3.7 | A name containing `.` or `:` is refused before dispatch (`IllegalArgumentException`), including workspace window names; 3.7a and later keep such names. | | `break-pane` on exact 3.7 | Literal calls and unnamed typed calls are refused. Use `Pane.breakOut(name)` or upgrade to 3.7a+. | diff --git a/capabilities.json b/capabilities.json new file mode 100644 index 000000000..73188c24f --- /dev/null +++ b/capabilities.json @@ -0,0 +1,287 @@ +{ + "capabilities": [ + { + "apis": [ + "Server.control", + "ControlClient.attach", + "ControlClient.attachUnfenced" + ], + "commands": [ + { + "command": "attach-session" + } + ], + "error": "UnsupportedFeatureException", + "handling": "refuse", + "id": "control-mode", + "name": "Control mode", + "since": "3.7", + "summary": "Attach a library-managed control client and stream events." + }, + { + "apis": [ + "Buffers.delete", + "Server.cmd", + "Server.run", + "Server.batch" + ], + "commands": [ + { + "command": "delete-buffer", + "flags": [ + "-b" + ] + } + ], + "error": "UnsupportedFeatureException", + "handling": "refuse", + "id": "buffer-delete-named", + "name": "Named buffer deletion", + "since": "3.4", + "summary": "Delete one paste buffer by name without risking another buffer." + }, + { + "apis": [ + "Server.cmd", + "Server.run", + "Server.batch" + ], + "commands": [ + { + "command": "set-buffer", + "flags": [ + "-b", + "-n" + ] + } + ], + "error": "UnsupportedFeatureException", + "handling": "refuse", + "id": "buffer-rename", + "name": "Named buffer rename", + "since": "3.4", + "summary": "Rename one paste buffer by name without risking another buffer." + }, + { + "apis": [ + "Server.cmd", + "Server.run", + "Server.batch" + ], + "commands": [ + { + "command": "select-layout" + } + ], + "error": "UnsupportedFeatureException", + "handling": "refuse", + "id": "layout-without-checksum", + "name": "Layout errors without a checksum", + "since": "3.4", + "summary": "select-layout reports a layout string that lacks the leading checksum instead of ending the server." + }, + { + "apis": [ + "Window.selectLayout", + "Layout.requireSupported" + ], + "commands": [ + { + "command": "select-layout" + } + ], + "error": "UnsupportedFeatureException", + "handling": "refuse", + "id": "layout-mirrored-main", + "name": "Mirrored main layouts", + "since": "3.5", + "summary": "Apply main-horizontal-mirrored and main-vertical-mirrored with select-layout." + }, + { + "apis": [ + "Window.selectLayout", + "Layouts.require" + ], + "commands": [ + { + "command": "select-layout" + } + ], + "error": "UnsupportedFeatureException", + "handling": "refuse", + "id": "layout-json", + "name": "JSON layouts", + "since": "3.8-rc2", + "summary": "Apply a layout in the JSON form that tmux reports." + }, + { + "apis": [ + "Pane.capture", + "CaptureSpec.Builder.trimmingTrailingSpace" + ], + "commands": [ + { + "command": "capture-pane", + "flags": [ + "-T" + ] + } + ], + "error": "UnsupportedFeatureException", + "handling": "refuse", + "id": "capture-trim-blank-cells", + "name": "Trim blank cells from a capture", + "since": "3.4", + "summary": "Stop a capture at the last used cell with capture-pane -T." + }, + { + "apis": [ + "Pane.capture", + "CaptureSpec.Builder.fromModeScreen" + ], + "commands": [ + { + "command": "capture-pane", + "flags": [ + "-M" + ] + } + ], + "error": "UnsupportedFeatureException", + "handling": "refuse", + "id": "capture-mode-screen", + "name": "Capture the mode screen", + "since": "3.6", + "summary": "Capture what a pane's mode, such as copy mode, is showing with capture-pane -M." + }, + { + "apis": [ + "Pane.capture", + "CaptureSpec.Builder.withHyperlinks", + "CaptureSpec.Builder.withLineNumbers" + ], + "commands": [ + { + "command": "capture-pane", + "flags": [ + "-H", + "-L" + ] + } + ], + "error": "UnsupportedFeatureException", + "handling": "refuse", + "id": "capture-line-flags", + "name": "Capture hyperlinks and line numbers", + "since": "3.7", + "summary": "Capture hyperlinks and line numbers with capture-pane -H and -L." + } + ], + "floor": { + "minimum": "3.3a" + }, + "port": "java", + "schema": "libtmux-capabilities/1", + "workarounds": [ + { + "apis": [ + "Shell.capturing" + ], + "commands": [ + { + "command": "run-shell" + } + ], + "handling": "refuse", + "id": "run-shell-output-in-copy-mode", + "name": "run-shell output goes to the pane", + "releases": [ + "3.3a", + "3.4" + ], + "summary": "With no target pane, run-shell sends job output to a pane's view mode instead of the command's own output, so Shell.capturing refuses." + }, + { + "apis": [ + "Pane.breakOut", + "Server.cmd", + "Server.run", + "Server.batch" + ], + "commands": [ + { + "command": "break-pane", + "flags": [ + "-n" + ] + } + ], + "handling": "repair", + "id": "break-pane-misnames-windows", + "name": "break-pane misnames the window", + "releases": [ + "3.7" + ], + "summary": "On 3.7, break-pane gives the new window the default name when -n is given, and a break with no name can end the server, so Pane.breakOut names the window afterwards and a literal or unnamed break is refused." + }, + { + "apis": [ + "Pane.split", + "Window.split", + "Server.cmd", + "Server.run", + "Server.batch" + ], + "commands": [ + { + "command": "split-window", + "flags": [ + "-p" + ] + } + ], + "handling": "refuse", + "id": "split-window-percent-size", + "name": "split-window -p reads -l", + "releases": [ + "3.4" + ], + "summary": "On 3.4, split-window -p reads its value from -l, so a literal -p is refused and the typed split sends the percentage with -l." + }, + { + "apis": [ + "Server.newSession", + "Session.newWindow", + "Session.rename", + "Window.rename" + ], + "commands": [ + { + "command": "new-session", + "flags": [ + "-s" + ] + }, + { + "command": "new-window", + "flags": [ + "-n" + ] + }, + { + "command": "rename-session" + }, + { + "command": "rename-window" + } + ], + "handling": "refuse", + "id": "name-separators-rejected", + "name": "Names with a colon or a period", + "releases": [ + "3.7" + ], + "summary": "On 3.7, new-session, new-window, rename-session and rename-window reject a name that contains a colon or a period, so the library refuses it first." + } + ] +} diff --git a/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java index 8b0f04af0..e06dfa894 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java @@ -31,16 +31,16 @@ void malformedLiteralLayoutsCannotApplyAnEarlierBatchCommand(Server server) { List.of("select-l", "--", "-n"), List.of("select-layout", "-ot", pane.id().value(), "tile"), List.of("select-layout", "0000,80x24,0,0,0"))) { - assertThrows(IllegalArgumentException.class, () -> server.cmd(command), command.toString()); + assertThrows(UnsupportedFeatureException.class, () -> server.cmd(command), command.toString()); } assertThrows( - IllegalArgumentException.class, + UnsupportedFeatureException.class, () -> server.batch() .add("set-option", "-g", "@layout-effect", "changed") .add("select-layout", "not-a-layout") .run()); assertThrows( - IllegalArgumentException.class, + UnsupportedFeatureException.class, () -> pane.batch() .add("set-option", "-g", "@layout-effect", "changed") .add("select-layout", "-o", "tiled") diff --git a/libtmux/build.gradle.kts b/libtmux/build.gradle.kts index b04eb8e99..1f0f60736 100644 --- a/libtmux/build.gradle.kts +++ b/libtmux/build.gradle.kts @@ -99,7 +99,13 @@ val moduleConsumer = tasks.check { dependsOn(moduleConsumer) } -tasks.named("test") { useJUnitPlatform { excludeTags("carrier") } } +tasks.named("test") { + useJUnitPlatform { excludeTags("carrier") } + // The drift test compares this file with the registry; a hand edit must rerun it, not hit the cache. + inputs.file(rootProject.file("capabilities.json")).withPropertyName("capabilitiesManifest") + .withPathSensitivity(PathSensitivity.NONE) + inputs.property("writeCapabilities", providers.environmentVariable("LIBTMUX_WRITE_CAPABILITIES").orElse("")) +} // The carrier gate needs a scheduler with exactly one carrier. That is a JVM-wide setting, so it // gets its own fork rather than distorting every other test in the suite. diff --git a/libtmux/src/main/java/io/github/libtmux/CaptureSpec.java b/libtmux/src/main/java/io/github/libtmux/CaptureSpec.java index 90bd1b4e1..23304cb08 100644 --- a/libtmux/src/main/java/io/github/libtmux/CaptureSpec.java +++ b/libtmux/src/main/java/io/github/libtmux/CaptureSpec.java @@ -1,6 +1,7 @@ package io.github.libtmux; import io.github.libtmux.exception.UnsupportedFeatureException; +import io.github.libtmux.internal.support.Capabilities; import java.util.ArrayList; import java.util.List; import org.jspecify.annotations.Nullable; @@ -31,11 +32,11 @@ public final class CaptureSpec { * line, so a prober that trusted the cluster's shape alone would be reading a line tmux itself * gets inconsistent. */ - private static final TmuxVersion TRIM_SINCE = new TmuxVersion(3, 4, ""); + private static final TmuxVersion TRIM_SINCE = Capabilities.CAPTURE_TRIM_BLANK_CELLS.since(); - private static final TmuxVersion MODE_SCREEN_SINCE = new TmuxVersion(3, 6, ""); + private static final TmuxVersion MODE_SCREEN_SINCE = Capabilities.CAPTURE_MODE_SCREEN.since(); - private static final TmuxVersion HYPERLINKS_SINCE = new TmuxVersion(3, 7, ""); + private static final TmuxVersion HYPERLINKS_SINCE = Capabilities.CAPTURE_LINE_FLAGS.since(); private final @Nullable String start; private final @Nullable String end; diff --git a/libtmux/src/main/java/io/github/libtmux/Layout.java b/libtmux/src/main/java/io/github/libtmux/Layout.java index 86bc8550a..b8ff86b07 100644 --- a/libtmux/src/main/java/io/github/libtmux/Layout.java +++ b/libtmux/src/main/java/io/github/libtmux/Layout.java @@ -1,6 +1,7 @@ package io.github.libtmux; import io.github.libtmux.exception.UnsupportedFeatureException; +import io.github.libtmux.internal.support.Capabilities; import java.util.Arrays; import java.util.Map; import java.util.Optional; @@ -62,7 +63,8 @@ public static Optional byTmuxName(String name) { */ Optional since() { return switch (this) { - case MAIN_HORIZONTAL_MIRRORED, MAIN_VERTICAL_MIRRORED -> Optional.of(new TmuxVersion(3, 5, "")); + case MAIN_HORIZONTAL_MIRRORED, MAIN_VERTICAL_MIRRORED -> + Optional.of(Capabilities.LAYOUT_MIRRORED_MAIN.since()); case EVEN_HORIZONTAL, EVEN_VERTICAL, MAIN_HORIZONTAL, MAIN_VERTICAL, TILED -> Optional.empty(); }; } diff --git a/libtmux/src/main/java/io/github/libtmux/Layouts.java b/libtmux/src/main/java/io/github/libtmux/Layouts.java index 448c7f20d..c907c7fbe 100644 --- a/libtmux/src/main/java/io/github/libtmux/Layouts.java +++ b/libtmux/src/main/java/io/github/libtmux/Layouts.java @@ -3,6 +3,7 @@ import io.github.libtmux.exception.LibTmuxException; import io.github.libtmux.exception.MalformedResponseException; import io.github.libtmux.exception.UnsupportedFeatureException; +import io.github.libtmux.internal.support.Capabilities; import java.util.Arrays; import java.util.List; import java.util.Optional; @@ -41,7 +42,14 @@ public final class Layouts { * anything that branch cannot parse ends the server outright on 3.3a. A JSON-shaped string is * therefore only something *this* tmux could have written once it is at least this version. */ - private static final TmuxVersion JSON_LAYOUT_SINCE = new TmuxVersion(3, 8, "", false, "rc2"); + private static final TmuxVersion JSON_LAYOUT_SINCE = Capabilities.LAYOUT_JSON.since(); + + /** + * A release that already reports layouts without a checksum and does not yet have the mirrored + * ones: the view of the built-in names that holds below {@link Capabilities#LAYOUT_MIRRORED_MAIN}. + * Read from the registry, so moving either release moves this check with it. + */ + private static final TmuxVersion WITHOUT_MIRRORED = Capabilities.LAYOUT_WITHOUT_CHECKSUM.since(); private Layouts() {} @@ -142,7 +150,7 @@ public static Optional builtIn(String layout, TmuxVersion running) { */ public static String require(String layout, Server server, int paneCount) { if (paneCount < 1) throw new IllegalArgumentException("pane count must be positive"); - if (!isJsonShaped(layout) && named(layout, new TmuxVersion(3, 4, "")).isEmpty()) require(layout); + if (!isJsonShaped(layout) && named(layout, WITHOUT_MIRRORED).isEmpty()) require(layout); if (isJsonShaped(layout)) { require(layout, version(server)); if (JsonLayout.leaves(layout) < paneCount) { @@ -155,8 +163,8 @@ public static String require(String layout, Server server, int paneCount) { if (cells < paneCount) throw new IllegalArgumentException("layout has fewer cells than panes: " + layout); return layout; } - Optional before = named(layout, new TmuxVersion(3, 4, "")); - Optional after = named(layout, new TmuxVersion(3, 5, "")); + Optional before = named(layout, WITHOUT_MIRRORED); + Optional after = named(layout, Capabilities.LAYOUT_MIRRORED_MAIN.since()); if (before.equals(after)) return before.orElseThrow().tmuxName(); TmuxVersion running = version(server); for (Layout candidate : Layout.values()) { diff --git a/libtmux/src/main/java/io/github/libtmux/Names.java b/libtmux/src/main/java/io/github/libtmux/Names.java index cb16ec387..1eddac499 100644 --- a/libtmux/src/main/java/io/github/libtmux/Names.java +++ b/libtmux/src/main/java/io/github/libtmux/Names.java @@ -1,5 +1,6 @@ package io.github.libtmux; +import io.github.libtmux.internal.support.Capabilities; import java.util.Objects; /** @@ -11,8 +12,6 @@ */ public final class Names { - private static final TmuxVersion REFUSES = new TmuxVersion(3, 7, ""); - private Names() {} /** @@ -35,7 +34,8 @@ public static String require(String name, Server server) { */ public static String require(String name, TmuxVersion running) { Objects.requireNonNull(name, "name"); - if (running.sameRelease(REFUSES) && (name.indexOf('.') >= 0 || name.indexOf(':') >= 0)) { + if (Capabilities.NAME_SEPARATORS_REJECTED.affects(running) + && (name.indexOf('.') >= 0 || name.indexOf(':') >= 0)) { throw new IllegalArgumentException( "tmux 3.7 refuses '.' and ':' in session and window names (3.7a and later keep them): " + name); } diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index 2d1896bb2..4beb09049 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -12,6 +12,7 @@ import io.github.libtmux.exception.UnencodableTextException; import io.github.libtmux.exception.UnsupportedFeatureException; import io.github.libtmux.format.RowFormat; +import io.github.libtmux.internal.support.Capabilities; import io.github.libtmux.snapshot.PaneState; import io.github.libtmux.snapshot.ServerSnapshot; import io.github.libtmux.snapshot.WindowContext; @@ -46,17 +47,6 @@ public final class Pane { private static final RowFormat CREATED = RowFormat.of("pane_id"); - /** - * tmux 3.7 exactly gets break-pane's naming wrong twice: it ends the whole server — every - * session on the socket — when it has to choose the name itself, and it silently discards a name - * that is given. Both are one inverted check in {@code cmd-break-pane.c} — {@code if (name != - * NULL)} where it meant {@code == NULL} - fixed by {@code 84291b02}, which {@code git tag - * --contains} places on 3.7a and nothing earlier, matching CHANGES' "3.7 TO 3.7a": "Fix crash in - * break-pane when no name is provided." Not probeable: break-pane's args are unchanged across the - * fix, so nothing in {@link Commands#list} distinguishes the two releases. - */ - private static final TmuxVersion BREAK_PANE_NAMING_BROKEN = new TmuxVersion(3, 7, ""); - private final Server server; private final ServerSnapshot snapshot; private final PaneState state; @@ -818,9 +808,16 @@ public Window breakOut(String windowName) { return breakNamed(Optional.of(windowName)); } + /** + * Breaks the pane out. Exactly tmux 3.7 gets break-pane's naming wrong twice: it ends the whole + * server when it has to choose the name itself, and it silently discards a name that is given. Both + * are one inverted check in {@code cmd-break-pane.c}, fixed by {@code 84291b02}, which {@code git + * tag --contains} places on 3.7a and nothing earlier. Not probeable: break-pane's args are unchanged + * across the fix, so nothing in {@link Commands#list} distinguishes the two releases. + */ private Window breakNamed(Optional wanted) { TmuxVersion running = server.version(snapshot); - if (wanted.isEmpty() && running.sameRelease(BREAK_PANE_NAMING_BROKEN)) { + if (wanted.isEmpty() && Capabilities.BREAK_PANE_MISNAMES_WINDOWS.affects(running)) { throw new UnsupportedFeatureException( "unnamed break-pane is unreliable on tmux 3.7; use breakOut(name) or tmux 3.7a+"); } @@ -836,7 +833,7 @@ private Window breakNamed(Optional wanted) { new SessionId(fields.get(0)), new WindowIndex(Integer.parseInt(fields.get(2))), new WindowId(fields.get(1))); - if (running.sameRelease(BREAK_PANE_NAMING_BROKEN)) { + if (Capabilities.BREAK_PANE_MISNAMES_WINDOWS.affects(running)) { // 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), "--", TmuxFormats.literal(name)))); diff --git a/libtmux/src/main/java/io/github/libtmux/Shell.java b/libtmux/src/main/java/io/github/libtmux/Shell.java index 0e6f560bb..1b766bf79 100644 --- a/libtmux/src/main/java/io/github/libtmux/Shell.java +++ b/libtmux/src/main/java/io/github/libtmux/Shell.java @@ -3,6 +3,7 @@ import io.github.libtmux.catalog.Kind; import io.github.libtmux.catalog.Operation; import io.github.libtmux.exception.UnsupportedFeatureException; +import io.github.libtmux.internal.support.Capabilities; import java.util.List; import java.util.Objects; import kotlin.annotations.jvm.ReadOnly; @@ -15,13 +16,6 @@ */ public final class Shell { - /** - * tmux lost {@code run-shell}'s output in 3.3a and found it again in 3.5; 3.3a is the first - * supported release, so everything below this one is the gap. The command exists on every - * release, so {@code list-commands} cannot tell the gap from the working releases. - */ - private static final TmuxVersion OUTPUT_FOUND = new TmuxVersion(3, 5, ""); - private final Server server; Shell(Server server) { @@ -46,7 +40,8 @@ public void run(String command) { public List capturing(String command) { Objects.requireNonNull(command, "command"); TmuxVersion running = server.version(); - if (!running.atLeast(OUTPUT_FOUND)) { + // The command exists on every release, so list-commands cannot tell the gap from the working ones. + if (Capabilities.RUN_SHELL_OUTPUT_IN_COPY_MODE.affects(running)) { throw new UnsupportedFeatureException( "reading what run-shell printed is broken between tmux 3.3a and 3.4, and this server runs " + running); diff --git a/libtmux/src/main/java/io/github/libtmux/internal/ControlAcquisition.java b/libtmux/src/main/java/io/github/libtmux/internal/ControlAcquisition.java index 3b7f47231..c8bd92fd2 100644 --- a/libtmux/src/main/java/io/github/libtmux/internal/ControlAcquisition.java +++ b/libtmux/src/main/java/io/github/libtmux/internal/ControlAcquisition.java @@ -7,6 +7,7 @@ import io.github.libtmux.exception.MalformedResponseException; import io.github.libtmux.exception.TargetGoneException; import io.github.libtmux.exception.UnsupportedFeatureException; +import io.github.libtmux.internal.support.Capabilities; import io.github.libtmux.internal.support.Versions; import io.github.libtmux.transport.CommandRequest; import io.github.libtmux.transport.CommandResult; @@ -24,7 +25,6 @@ /** Qualifies a carrier-owned endpoint before the first control effect. */ public final class ControlAcquisition implements AutoCloseable { private static final String FORMAT = "#{version},#{pid},#{start_time},'#{s/'/'\\\\''/:socket_path}'"; - private static final TmuxVersion MINIMUM = new TmuxVersion(3, 7, ""); private final ControlCarrier.PinnedSocket pin; private final Identity identity; private final List command; @@ -89,7 +89,7 @@ public static ControlAcquisition acquire(ControlCarrier carrier, ServerConfig co * {@code 3.8-rc2} or {@code next-3.8} rank above. */ public static boolean supports(TmuxVersion version) { - return version.compareTo(MINIMUM) >= 0; + return Capabilities.CONTROL_MODE.availableOn(version); } /** diff --git a/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java b/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java index fd7ed7940..8393c1861 100644 --- a/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java +++ b/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java @@ -4,17 +4,13 @@ import io.github.libtmux.Names; import io.github.libtmux.TmuxVersion; import io.github.libtmux.exception.UnsupportedFeatureException; +import io.github.libtmux.internal.support.Capabilities; import java.util.HashSet; import java.util.List; import java.util.Set; /** Feature boundaries for literal commands and the commands hidden by managed guards. */ public final class TmuxFeatures { - private static final TmuxVersion EXACT_BUFFER_LOOKUP = new TmuxVersion(3, 4, ""); - private static final TmuxVersion LAYOUT_ERROR_REPORTING = new TmuxVersion(3, 4, ""); - private static final TmuxVersion SPLIT_PERCENT_BROKEN = new TmuxVersion(3, 4, ""); - private static final TmuxVersion BREAK_BROKEN = new TmuxVersion(3, 7, ""); - private TmuxFeatures() {} /** Original commands remain separate from their encoded incarnation guard. */ @@ -29,31 +25,32 @@ public static Intent literal(List> commands) { } public static void require(TmuxVersion version, Intent intent) { - if (intent.namedBufferCleanup() && !version.atLeast(EXACT_BUFFER_LOOKUP)) { - throw new UnsupportedFeatureException("managed paste buffer cleanup", EXACT_BUFFER_LOOKUP, version); + if (intent.namedBufferCleanup() && !Capabilities.BUFFER_DELETE_NAMED.availableOn(version)) { + throw new UnsupportedFeatureException( + "managed paste buffer cleanup", Capabilities.BUFFER_DELETE_NAMED.since(), version); } for (List command : intent.commands()) { if (command.isEmpty()) continue; String verb = command.getFirst(); if (matches(verb, "delete-buffer", "deleteb", 3)) { - if (flags(command, "b").contains('b') && !version.atLeast(EXACT_BUFFER_LOOKUP)) { + if (flags(command, "b").contains('b') && !Capabilities.BUFFER_DELETE_NAMED.availableOn(version)) { throw new UnsupportedFeatureException( - "deleting a buffer by exact name", EXACT_BUFFER_LOOKUP, version); + "deleting a buffer by exact name", Capabilities.BUFFER_DELETE_NAMED.since(), version); } } else if (matches(verb, "set-buffer", "setb", 5)) { Set flags = flags(command, "btn"); - if (flags.contains('b') && flags.contains('n') && !version.atLeast(EXACT_BUFFER_LOOKUP)) { + if (flags.contains('b') && flags.contains('n') && !Capabilities.BUFFER_RENAME.availableOn(version)) { throw new UnsupportedFeatureException( - "renaming a buffer by exact name", EXACT_BUFFER_LOOKUP, version); + "renaming a buffer by exact name", Capabilities.BUFFER_RENAME.since(), version); } } else if (matches(verb, "split-window", "splitw", 2)) { - if (version.sameRelease(SPLIT_PERCENT_BROKEN) + if (Capabilities.SPLIT_WINDOW_PERCENT_SIZE.affects(version) && flags(command, "ceFlpsStRmxyXYL").contains('p')) { throw new UnsupportedFeatureException( "tmux 3.4 split-window -p is unreliable; use -l with a percentage"); } } else if (matches(verb, "break-pane", "breakp", 2) - && version.sameRelease(BREAK_BROKEN) + && Capabilities.BREAK_PANE_MISNAMES_WINDOWS.affects(version) && !intent.repairedBreak()) { throw new UnsupportedFeatureException( "literal break-pane and unnamed breaks are unreliable on tmux 3.7; use Pane.breakOut(name) or tmux 3.7a+"); @@ -63,7 +60,8 @@ && flags(command, "ceFlpsStRmxyXYL").contains('p')) { names(version, command, "cenFt", "n"); } else if (matches(verb, "rename-session", "rename", 8) || matches(verb, "rename-window", "renamew", 8)) { renamed(version, command); - } else if (matches(verb, "select-layout", "selectl", 8) && !version.atLeast(LAYOUT_ERROR_REPORTING)) { + } else if (matches(verb, "select-layout", "selectl", 8) + && !Capabilities.LAYOUT_WITHOUT_CHECKSUM.availableOn(version)) { Set flags = new HashSet<>(); int operand = options(command, "t", flags); if (operand < command.size() && !flags.contains('n') && !flags.contains('p') && !flags.contains('E')) { @@ -72,11 +70,21 @@ && flags(command, "ceFlpsStRmxyXYL").contains('p')) { // checksummed. A checksummed string is tmux's to accept or report, geometry // included, so only the checksum is checked here. if (!checksummed(layout)) { - Layouts.require(layout, version); - if (flags.contains('o') - && Layouts.builtIn(layout, version).isPresent()) { - throw new IllegalArgumentException( - "select-layout -o requires a layout tmux wrote: " + layout); + try { + Layouts.require(layout, version); + if (flags.contains('o') + && Layouts.builtIn(layout, version).isPresent()) { + throw new IllegalArgumentException( + "select-layout -o requires a layout tmux wrote: " + layout); + } + } catch (IllegalArgumentException invalid) { + UnsupportedFeatureException refused = new UnsupportedFeatureException( + "select-layout with a layout without tmux's checksum (" + invalid.getMessage() + + ")", + Capabilities.LAYOUT_WITHOUT_CHECKSUM.since(), + version); + refused.addSuppressed(invalid); + throw refused; } } } diff --git a/libtmux/src/main/java/io/github/libtmux/internal/support/Capabilities.java b/libtmux/src/main/java/io/github/libtmux/internal/support/Capabilities.java new file mode 100644 index 000000000..69ad06e3d --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/internal/support/Capabilities.java @@ -0,0 +1,410 @@ +package io.github.libtmux.internal.support; + +import io.github.libtmux.TmuxVersion; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.jspecify.annotations.Nullable; + +/** + * What the supported daemons can do, and the release defects this library works around. + * + *

One table decides both behaviour and the published manifest: the gates in {@code TmuxFeatures}, + * {@code ControlAcquisition}, {@code Layouts} and the rest read their releases from the records + * here, and {@link #manifest()} writes the same records out as the root {@code capabilities.json} + * that the documentation builds its capability pages from. Capabilities are inferred from the + * daemon's version; tmux does not advertise them. The IDs are the shared vocabulary of the libtmux + * ports, and a quirk that no caller can use stays a {@link Workaround}. + */ +public final class Capabilities { + + private Capabilities() {} + + /** What a gate does on a release that lacks the behaviour. */ + public enum Handling { + REFUSE, + FALLBACK, + REPAIR; + + String json() { + return name().toLowerCase(java.util.Locale.ROOT); + } + } + + /** A tmux command a claim concerns, and the single-letter flags of it that matter. */ + public record Command(String command, List flags) { + public Command { + flags = List.copyOf(flags); + } + } + + /** + * A feature that exists from one release on. + * + * @param since the first release that has it + * @param except exact releases at or above {@code since} that do not + * @param error the simple name of the exception a refusal raises, when {@code handling} is + * {@link Handling#REFUSE} + * @param apis the public API names that need it, as the generated API reference spells them + */ + public record Capability( + String id, + String name, + String summary, + TmuxVersion since, + List except, + Handling handling, + @Nullable String error, + List apis, + List commands) { + public Capability { + except = List.copyOf(except); + apis = List.copyOf(apis); + commands = List.copyOf(commands); + if (handling == Handling.REFUSE && error == null) { + throw new IllegalArgumentException(id + " refuses, so it names its error"); + } + } + + /** Whether the daemon has the behaviour. */ + public boolean availableOn(TmuxVersion running) { + return running.atLeast(since) && except.stream().noneMatch(running::sameRelease); + } + } + + /** + * A defect in exact releases that this library refuses or repairs. Not a capability. + * + * @param releases the affected releases the manifest lists + * @param until the first release that no longer has the defect, for a defect that spans every + * build between the first listed release and it, development builds included; null when + * only the listed releases are affected + */ + public record Workaround( + String id, + String name, + String summary, + List releases, + @Nullable TmuxVersion until, + Handling handling, + List apis, + List commands) { + public Workaround { + releases = List.copyOf(releases); + apis = List.copyOf(apis); + commands = List.copyOf(commands); + if (releases.isEmpty() || handling == Handling.FALLBACK) { + throw new IllegalArgumentException(id + " names its releases and refuses or repairs"); + } + } + + /** Whether the daemon has the defect. */ + public boolean affects(TmuxVersion running) { + if (until != null) { + return running.atLeast(releases.getFirst()) && running.compareTo(until) < 0; + } + return releases.stream().anyMatch(running::sameRelease); + } + } + + private static final String REFUSAL = "UnsupportedFeatureException"; + + public static final Capability CONTROL_MODE = new Capability( + "control-mode", + "Control mode", + "Attach a library-managed control client and stream events.", + new TmuxVersion(3, 7, ""), + List.of(), + Handling.REFUSE, + REFUSAL, + List.of("Server.control", "ControlClient.attach", "ControlClient.attachUnfenced"), + List.of(new Command("attach-session", List.of()))); + + public static final Capability BUFFER_DELETE_NAMED = new Capability( + "buffer-delete-named", + "Named buffer deletion", + "Delete one paste buffer by name without risking another buffer.", + new TmuxVersion(3, 4, ""), + List.of(), + Handling.REFUSE, + REFUSAL, + List.of("Buffers.delete", "Server.cmd", "Server.run", "Server.batch"), + List.of(new Command("delete-buffer", List.of("b")))); + + public static final Capability BUFFER_RENAME = new Capability( + "buffer-rename", + "Named buffer rename", + "Rename one paste buffer by name without risking another buffer.", + new TmuxVersion(3, 4, ""), + List.of(), + Handling.REFUSE, + REFUSAL, + List.of("Server.cmd", "Server.run", "Server.batch"), + List.of(new Command("set-buffer", List.of("b", "n")))); + + public static final Capability LAYOUT_WITHOUT_CHECKSUM = new Capability( + "layout-without-checksum", + "Layout errors without a checksum", + "select-layout reports a layout string that lacks the leading checksum instead of ending the server.", + new TmuxVersion(3, 4, ""), + List.of(), + Handling.REFUSE, + REFUSAL, + List.of("Server.cmd", "Server.run", "Server.batch"), + List.of(new Command("select-layout", List.of()))); + + public static final Capability LAYOUT_MIRRORED_MAIN = new Capability( + "layout-mirrored-main", + "Mirrored main layouts", + "Apply main-horizontal-mirrored and main-vertical-mirrored with select-layout.", + new TmuxVersion(3, 5, ""), + List.of(), + Handling.REFUSE, + REFUSAL, + List.of("Window.selectLayout", "Layout.requireSupported"), + List.of(new Command("select-layout", List.of()))); + + public static final Capability LAYOUT_JSON = new Capability( + "layout-json", + "JSON layouts", + "Apply a layout in the JSON form that tmux reports.", + TmuxVersion.parse("3.8-rc2"), + List.of(), + Handling.REFUSE, + REFUSAL, + List.of("Window.selectLayout", "Layouts.require"), + List.of(new Command("select-layout", List.of()))); + + public static final Capability CAPTURE_MODE_SCREEN = new Capability( + "capture-mode-screen", + "Capture the mode screen", + "Capture what a pane's mode, such as copy mode, is showing with capture-pane -M.", + new TmuxVersion(3, 6, ""), + List.of(), + Handling.REFUSE, + REFUSAL, + List.of("Pane.capture", "CaptureSpec.Builder.fromModeScreen"), + List.of(new Command("capture-pane", List.of("M")))); + + public static final Capability CAPTURE_LINE_FLAGS = new Capability( + "capture-line-flags", + "Capture hyperlinks and line numbers", + "Capture hyperlinks and line numbers with capture-pane -H and -L.", + new TmuxVersion(3, 7, ""), + List.of(), + Handling.REFUSE, + REFUSAL, + List.of("Pane.capture", "CaptureSpec.Builder.withHyperlinks", "CaptureSpec.Builder.withLineNumbers"), + List.of(new Command("capture-pane", List.of("H", "L")))); + + public static final Capability CAPTURE_TRIM_BLANK_CELLS = new Capability( + "capture-trim-blank-cells", + "Trim blank cells from a capture", + "Stop a capture at the last used cell with capture-pane -T.", + new TmuxVersion(3, 4, ""), + List.of(), + Handling.REFUSE, + REFUSAL, + List.of("Pane.capture", "CaptureSpec.Builder.trimmingTrailingSpace"), + List.of(new Command("capture-pane", List.of("T")))); + + public static final Workaround RUN_SHELL_OUTPUT_IN_COPY_MODE = new Workaround( + "run-shell-output-in-copy-mode", + "run-shell output goes to the pane", + "With no target pane, run-shell sends job output to a pane's view mode instead of the" + + " command's own output, so Shell.capturing refuses.", + List.of(new TmuxVersion(3, 3, "a"), new TmuxVersion(3, 4, "")), + new TmuxVersion(3, 5, ""), + Handling.REFUSE, + List.of("Shell.capturing"), + List.of(new Command("run-shell", List.of()))); + + public static final Workaround BREAK_PANE_MISNAMES_WINDOWS = new Workaround( + "break-pane-misnames-windows", + "break-pane misnames the window", + "On 3.7, break-pane gives the new window the default name when -n is given, and a break with no" + + " name can end the server, so Pane.breakOut names the window afterwards and a literal or" + + " unnamed break is refused.", + List.of(new TmuxVersion(3, 7, "")), + null, + Handling.REPAIR, + List.of("Pane.breakOut", "Server.cmd", "Server.run", "Server.batch"), + List.of(new Command("break-pane", List.of("n")))); + + public static final Workaround SPLIT_WINDOW_PERCENT_SIZE = new Workaround( + "split-window-percent-size", + "split-window -p reads -l", + "On 3.4, split-window -p reads its value from -l, so a literal -p is refused and the typed" + + " split sends the percentage with -l.", + List.of(new TmuxVersion(3, 4, "")), + null, + Handling.REFUSE, + List.of("Pane.split", "Window.split", "Server.cmd", "Server.run", "Server.batch"), + List.of(new Command("split-window", List.of("p")))); + + public static final Workaround NAME_SEPARATORS_REJECTED = new Workaround( + "name-separators-rejected", + "Names with a colon or a period", + "On 3.7, new-session, new-window, rename-session and rename-window reject a name that" + + " contains a colon or a period, so the library refuses it first.", + List.of(new TmuxVersion(3, 7, "")), + null, + Handling.REFUSE, + List.of("Server.newSession", "Session.newWindow", "Session.rename", "Window.rename"), + List.of( + new Command("new-session", List.of("s")), + new Command("new-window", List.of("n")), + new Command("rename-session", List.of()), + new Command("rename-window", List.of()))); + + private static final List CAPABILITIES = List.of( + CONTROL_MODE, + BUFFER_DELETE_NAMED, + BUFFER_RENAME, + LAYOUT_WITHOUT_CHECKSUM, + LAYOUT_MIRRORED_MAIN, + LAYOUT_JSON, + CAPTURE_TRIM_BLANK_CELLS, + CAPTURE_MODE_SCREEN, + CAPTURE_LINE_FLAGS); + + private static final List WORKAROUNDS = List.of( + RUN_SHELL_OUTPUT_IN_COPY_MODE, + BREAK_PANE_MISNAMES_WINDOWS, + SPLIT_WINDOW_PERCENT_SIZE, + NAME_SEPARATORS_REJECTED); + + /** Every capability the library gates, in manifest order. */ + public static List capabilities() { + return CAPABILITIES; + } + + /** Every release defect the library refuses or repairs, in manifest order. */ + public static List workarounds() { + return WORKAROUNDS; + } + + /** + * The manifest the documentation reads, as the root {@code capabilities.json}: deterministic, + * keys sorted, two-space indent, one trailing newline. + */ + public static String manifest() { + Map root = new TreeMap<>(); + root.put("schema", "libtmux-capabilities/1"); + root.put("port", "java"); + root.put("floor", new TreeMap<>(Map.of("minimum", TmuxVersion.MINIMUM.toString()))); + root.put("capabilities", CAPABILITIES.stream().map(Capabilities::entry).toList()); + root.put("workarounds", WORKAROUNDS.stream().map(Capabilities::entry).toList()); + StringBuilder out = new StringBuilder(); + write(out, root, 0); + return out.append('\n').toString(); + } + + private static Map entry(Capability capability) { + Map entry = new TreeMap<>(); + entry.put("id", capability.id()); + entry.put("name", capability.name()); + entry.put("summary", capability.summary()); + entry.put("since", capability.since().toString()); + if (!capability.except().isEmpty()) { + entry.put( + "except", + capability.except().stream().map(TmuxVersion::toString).toList()); + } + entry.put("handling", capability.handling().json()); + if (capability.error() != null) { + entry.put("error", capability.error()); + } + entry.put("apis", capability.apis()); + entry.put("commands", commands(capability.commands())); + return entry; + } + + private static Map entry(Workaround workaround) { + Map entry = new TreeMap<>(); + entry.put("id", workaround.id()); + entry.put("name", workaround.name()); + entry.put("summary", workaround.summary()); + entry.put( + "releases", + workaround.releases().stream().map(TmuxVersion::toString).toList()); + entry.put("handling", workaround.handling().json()); + entry.put("apis", workaround.apis()); + entry.put("commands", commands(workaround.commands())); + return entry; + } + + private static List> commands(List commands) { + List> result = new ArrayList<>(); + for (Command command : commands) { + Map entry = new TreeMap<>(); + entry.put("command", command.command()); + if (!command.flags().isEmpty()) { + entry.put( + "flags", + command.flags().stream().map(flag -> "-" + flag).toList()); + } + result.add(entry); + } + return result; + } + + private static void write(StringBuilder out, Object value, int depth) { + String indent = " ".repeat(depth + 1); + String closing = " ".repeat(depth); + if (value instanceof Map map) { + if (map.isEmpty()) { + out.append("{}"); + return; + } + out.append("{\n"); + boolean first = true; + for (Map.Entry member : map.entrySet()) { + if (!first) out.append(",\n"); + first = false; + out.append(indent); + string(out, (String) member.getKey()); + out.append(": "); + write(out, member.getValue(), depth + 1); + } + out.append('\n').append(closing).append('}'); + } else if (value instanceof List list) { + if (list.isEmpty()) { + out.append("[]"); + return; + } + out.append("[\n"); + boolean first = true; + for (Object item : list) { + if (!first) out.append(",\n"); + first = false; + out.append(indent); + write(out, item, depth + 1); + } + out.append('\n').append(closing).append(']'); + } else { + string(out, (String) value); + } + } + + private static void string(StringBuilder out, String text) { + out.append('"'); + for (int index = 0; index < text.length(); index++) { + char c = text.charAt(index); + switch (c) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\n' -> out.append("\\n"); + default -> { + if (c < 0x20) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + } + } + out.append('"'); + } +} diff --git a/libtmux/src/test/java/io/github/libtmux/CaptureSpecRegistryTest.java b/libtmux/src/test/java/io/github/libtmux/CaptureSpecRegistryTest.java new file mode 100644 index 000000000..2b1a8415d --- /dev/null +++ b/libtmux/src/test/java/io/github/libtmux/CaptureSpecRegistryTest.java @@ -0,0 +1,38 @@ +package io.github.libtmux; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.github.libtmux.exception.UnsupportedFeatureException; +import io.github.libtmux.internal.support.Capabilities; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +/** The capture flags are gated at the releases the registry names, not at releases of their own. */ +final class CaptureSpecRegistryTest { + + private static void gated(String first, String lastRefused, Consumer flag) { + CaptureSpec.Builder builder = CaptureSpec.builder(); + flag.accept(builder); + CaptureSpec spec = builder.build(); + assertDoesNotThrow(() -> spec.argv("%1", TmuxVersion.parse(first)), "allowed on " + first); + assertThrows( + UnsupportedFeatureException.class, + () -> spec.argv("%1", TmuxVersion.parse(lastRefused)), + "refused on " + lastRefused); + } + + @Test + void theModeScreenArrivedInTmux36() { + gated("3.6", "3.5a", CaptureSpec.Builder::fromModeScreen); + assertEquals("3.6", Capabilities.CAPTURE_MODE_SCREEN.since().toString()); + } + + @Test + void hyperlinksAndLineNumbersArrivedInTmux37() { + gated("3.7", "3.6b", CaptureSpec.Builder::withHyperlinks); + gated("3.7", "3.6b", CaptureSpec.Builder::withLineNumbers); + assertEquals("3.7", Capabilities.CAPTURE_LINE_FLAGS.since().toString()); + } +} diff --git a/libtmux/src/test/java/io/github/libtmux/internal/support/CapabilitiesTest.java b/libtmux/src/test/java/io/github/libtmux/internal/support/CapabilitiesTest.java new file mode 100644 index 000000000..4a0e1b349 --- /dev/null +++ b/libtmux/src/test/java/io/github/libtmux/internal/support/CapabilitiesTest.java @@ -0,0 +1,210 @@ +package io.github.libtmux.internal.support; + +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.Layout; +import io.github.libtmux.Layouts; +import io.github.libtmux.Names; +import io.github.libtmux.TmuxVersion; +import io.github.libtmux.exception.UnsupportedFeatureException; +import io.github.libtmux.internal.ControlAcquisition; +import io.github.libtmux.internal.TmuxFeatures; +import java.io.IOException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +/** + * The registry is the one table the gates and the published manifest both read, so these tests hold + * the manifest to the registry, the registry to its own rules, and each gate to the registry. + */ +final class CapabilitiesTest { + private static final String WRITE = "LIBTMUX_WRITE_CAPABILITIES"; + private static final Pattern KEBAB = Pattern.compile("[a-z0-9]+(-[a-z0-9]+)*"); + private static final List PACKAGES = + List.of("io.github.libtmux.", "io.github.libtmux.control.", "io.github.libtmux.exception.", "java.lang."); + + /** The root {@code capabilities.json}; set {@value #WRITE} to 1 to rewrite it from the registry. */ + @Test + void theCommittedManifestIsTheRegistry() throws IOException { + Path manifest = root().resolve("capabilities.json"); + if ("1".equals(System.getenv(WRITE))) { + Files.writeString(manifest, Capabilities.manifest(), StandardCharsets.UTF_8); + } + assertEquals( + Capabilities.manifest(), + Files.readString(manifest, StandardCharsets.UTF_8), + manifest + " differs from the registry; run with " + WRITE + "=1 to regenerate it"); + } + + @Test + void everyIdIsKebabCaseAndAppearsOnce() { + Set seen = new HashSet<>(); + List ids = new ArrayList<>(); + Capabilities.capabilities().forEach(capability -> ids.add(capability.id())); + Capabilities.workarounds().forEach(workaround -> ids.add(workaround.id())); + for (String id : ids) { + assertTrue(KEBAB.matcher(id).matches(), id); + assertTrue(seen.add(id), id + " appears twice"); + } + } + + @Test + void everyNamedApiAndErrorIsAPublicSymbol() { + List names = new ArrayList<>(); + Capabilities.capabilities().forEach(capability -> { + names.addAll(capability.apis()); + if (capability.error() != null) names.add(capability.error()); + }); + Capabilities.workarounds().forEach(workaround -> names.addAll(workaround.apis())); + for (String name : names) { + assertTrue(resolves(name), name + " is not a public type or method"); + } + } + + @Test + void aCapabilityIsAvailableFromItsReleaseAndNotBefore() { + for (Capabilities.Capability capability : Capabilities.capabilities()) { + assertTrue(capability.availableOn(capability.since()), capability.id()); + assertTrue(capability.availableOn(TmuxVersion.parse("next-9.9")), capability.id()); + assertFalse( + capability.availableOn( + TmuxVersion.parse("next-" + capability.since().major() + "." + + capability.since().minor())), + capability.id()); + } + } + + @Test + void aWorkaroundAffectsItsListedReleasesAndNotTheNextOne() { + for (Capabilities.Workaround workaround : Capabilities.workarounds()) { + for (TmuxVersion release : workaround.releases()) { + assertTrue(workaround.affects(release), workaround.id() + " " + release); + } + assertFalse(workaround.affects(TmuxVersion.parse("9.9")), workaround.id()); + if (workaround.until() != null) { + assertFalse(workaround.affects(workaround.until()), workaround.id()); + } + } + } + + @Test + void theFloorIsTheMinimumTheVersionCheckEnforces() throws IOException { + String json = Capabilities.manifest(); + assertTrue(json.contains("\"minimum\": \"" + TmuxVersion.MINIMUM + "\""), json); + } + + @Test + void controlIsRefusedBelowTheRegistryReleaseAndAllowedFromIt() { + TmuxVersion since = Capabilities.CONTROL_MODE.since(); + assertTrue(ControlAcquisition.supports(since)); + assertFalse(ControlAcquisition.supports(TmuxVersion.parse("3.6b"))); + assertThrows( + UnsupportedFeatureException.class, + () -> ControlAcquisition.requireSupported(TmuxVersion.parse("3.6b"))); + } + + @Test + void namedBufferCommandsAreRefusedBelowTheirReleaseOnly() { + TmuxVersion before = TmuxVersion.parse("3.3a"); + List> deletes = List.of(List.of("delete-buffer", "-b", "x")); + List> renames = List.of(List.of("set-buffer", "-b", "x", "-n", "y")); + assertThrows( + UnsupportedFeatureException.class, + () -> TmuxFeatures.require(before, TmuxFeatures.Intent.literal(deletes))); + assertThrows( + UnsupportedFeatureException.class, + () -> TmuxFeatures.require(before, TmuxFeatures.Intent.literal(renames))); + TmuxFeatures.require(Capabilities.BUFFER_DELETE_NAMED.since(), TmuxFeatures.Intent.literal(deletes)); + TmuxFeatures.require(Capabilities.BUFFER_RENAME.since(), TmuxFeatures.Intent.literal(renames)); + } + + @Test + void literalPercentSplitAndLiteralBreakAreRefusedOnTheirExactReleases() { + List> split = List.of(List.of("split-window", "-p", "30")); + List> breakOut = List.of(List.of("break-pane", "-s", "%1")); + TmuxVersion splitRelease = + Capabilities.SPLIT_WINDOW_PERCENT_SIZE.releases().getFirst(); + TmuxVersion breakRelease = + Capabilities.BREAK_PANE_MISNAMES_WINDOWS.releases().getFirst(); + assertThrows( + UnsupportedFeatureException.class, + () -> TmuxFeatures.require(splitRelease, TmuxFeatures.Intent.literal(split))); + assertThrows( + UnsupportedFeatureException.class, + () -> TmuxFeatures.require(breakRelease, TmuxFeatures.Intent.literal(breakOut))); + TmuxFeatures.require(TmuxVersion.parse("3.5"), TmuxFeatures.Intent.literal(split)); + TmuxFeatures.require(TmuxVersion.parse("3.7a"), TmuxFeatures.Intent.literal(breakOut)); + } + + @Test + void layoutsAndNamesFollowTheirRegistryReleases() { + assertThrows( + UnsupportedFeatureException.class, + () -> Layout.MAIN_VERTICAL_MIRRORED.requireSupported(TmuxVersion.parse("3.4"))); + Layout.MAIN_VERTICAL_MIRRORED.requireSupported(Capabilities.LAYOUT_MIRRORED_MAIN.since()); + assertThrows(UnsupportedFeatureException.class, () -> Layouts.require("{\"V\":2}", TmuxVersion.parse("3.7c"))); + assertEquals("{\"V\":2}", Layouts.require("{\"V\":2}", Capabilities.LAYOUT_JSON.since())); + assertThrows( + IllegalArgumentException.class, + () -> Names.require( + "a:b", Capabilities.NAME_SEPARATORS_REJECTED.releases().getFirst())); + } + + /** Whether {@code Type.member} (or {@code Outer.Inner.member}, or a bare type) is public. */ + private static boolean resolves(String name) { + String[] parts = name.split("\\.", -1); + Optional> type = Optional.empty(); + int used = 0; + for (String prefix : PACKAGES) { + try { + type = Optional.of(Class.forName(prefix + parts[0])); + used = 1; + break; + } catch (ClassNotFoundException notHere) { + // Try the next package. + } + } + if (type.isEmpty()) return false; + Class current = type.get(); + while (used < parts.length) { + String part = parts[used++]; + Optional> nested = Optional.empty(); + for (Class candidate : current.getClasses()) { + if (candidate.getSimpleName().equals(part)) nested = Optional.of(candidate); + } + if (nested.isPresent()) { + current = nested.get(); + continue; + } + if (used != parts.length) return false; + for (Method method : current.getMethods()) { + if (method.getName().equals(part) && Modifier.isPublic(method.getModifiers())) return true; + } + return false; + } + return Modifier.isPublic(current.getModifiers()); + } + + private static Path root() { + Path directory = Path.of("").toAbsolutePath(); + for (Path candidate = directory; candidate != null; candidate = candidate.getParent()) { + if (Files.exists(candidate.resolve("settings.gradle.kts"))) { + return candidate; + } + } + throw new IllegalStateException("no settings.gradle.kts above " + directory); + } +} From d8f48b8cdc2db7756a8e8c7bcf3317063bf7c416 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Oct 2026 00:20:17 -0500 Subject: [PATCH 13/18] Features(fix): Refuse refresh-client -l below tmux 3.6a why: On tmux 3.3a, `refresh-client -l` with a client attached from an xterm or tmux-256color terminal ends the server. On 3.4 to 3.6 it ends the server when the client leaves before its terminal answers: with a pty client attached, the daemon died in 2 of 6 runs on 3.4 and in every run on 3.5 and 3.6, and 3.6a, 3.6b, 3.7c and 3.8 were clean. The literal command guard refused the other commands that end a supported daemon but not this one, so one `Server.cmd`, batch, chain or control line could take down a 3.3a server. what: - TmuxFeatures refuses refresh-client carrying -l below 3.6a under every name tmux resolves to it (`refresh-client`, `refresh`, any unique prefix from `ref`), in clusters and after valued flags, before anything is sent. The valued flags are those of the command's own argument table. - Record the gate as `clipboard-query` in the registry and regenerate capabilities.json. - Unit-test the spellings on 3.3a, 3.4, 3.5a and 3.6, and test on a real daemon with a pty client attached that the refusal holds and the daemon and client stay up. - Test on real tmux that a client detached in the same invocation as the query cannot end the daemon; with the floor lowered to 3.4 that test fails on 3.5. - List the requirement in MIGRATION.md. --- MIGRATION.md | 1 + capabilities.json | 21 +++++ .../it/OptionalFeatureIntegrationTest.java | 83 +++++++++++++++++++ .../github/libtmux/internal/TmuxFeatures.java | 12 +++ .../internal/support/Capabilities.java | 16 +++- .../internal/support/CapabilitiesTest.java | 28 +++++++ 6 files changed, 160 insertions(+), 1 deletion(-) diff --git a/MIGRATION.md b/MIGRATION.md index 6e36e35d5..8266f9565 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -86,6 +86,7 @@ of its commands, including a preceding command that would otherwise succeed. | `Pane.paste(text)` | tmux 3.4+ before creating a temporary buffer, because failure cleanup needs exact named deletion. | | Direct `paste-buffer -d -b name` | Retained on 3.3a; native lookup already rejects a missing name. | | Literal `select-layout` on 3.3a | Invalid operands are refused with `UnsupportedFeatureException` before every batch effect. Presets, unique prefixes and serialized layouts remain available; `-o` requires a serialized operand. | +| Clipboard query | Literal `refresh-client -l`, under any name tmux resolves to it, is refused below tmux 3.6a: on 3.3a it ends the server when a client attached from an `xterm` or `tmux-256color` terminal receives the query, and on 3.4 to 3.6 it ends the server when the client leaves before its terminal answers. | | Percentage splitting | Typed `SplitSpec.percent` retains `-l N%`. Literal `split-window -p` is refused on exact 3.4. | | Session and window names on exact 3.7 | A name containing `.` or `:` is refused before dispatch (`IllegalArgumentException`), including workspace window names; 3.7a and later keep such names. | | `break-pane` on exact 3.7 | Literal calls and unnamed typed calls are refused. Use `Pane.breakOut(name)` or upgrade to 3.7a+. | diff --git a/capabilities.json b/capabilities.json index 73188c24f..300b759a7 100644 --- a/capabilities.json +++ b/capabilities.json @@ -175,6 +175,27 @@ "name": "Capture hyperlinks and line numbers", "since": "3.7", "summary": "Capture hyperlinks and line numbers with capture-pane -H and -L." + }, + { + "apis": [ + "Server.cmd", + "Server.run", + "Server.batch" + ], + "commands": [ + { + "command": "refresh-client", + "flags": [ + "-l" + ] + } + ], + "error": "UnsupportedFeatureException", + "handling": "refuse", + "id": "clipboard-query", + "name": "Clipboard query", + "since": "3.6a", + "summary": "Ask a client's terminal for its clipboard with refresh-client -l. On 3.3a the server exits when a client attached from xterm or tmux-256color receives it, and on 3.4 to 3.6 it exits when the client leaves before its terminal answers." } ], "floor": { diff --git a/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java index e06dfa894..f192eb545 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java @@ -20,6 +20,9 @@ @ExtendWith(TmuxExtension.class) final class OptionalFeatureIntegrationTest { private static final TmuxVersion BUFFER = new TmuxVersion(3, 4, ""); + // Mirrors Capabilities.CLIPBOARD_QUERY.since(), which this module cannot reach; the registry + // test pins that value. + private static final TmuxVersion CLIPBOARD = new TmuxVersion(3, 6, "a"); @Test void malformedLiteralLayoutsCannotApplyAnEarlierBatchCommand(Server server) { @@ -54,6 +57,86 @@ void malformedLiteralLayoutsCannotApplyAnEarlierBatchCommand(Server server) { assertTrue(server.isAlive()); } + /** + * {@code refresh-client -l} asks the attached terminal for its clipboard, and on 3.3a the server + * exits when the terminal answers. A pty client is attached because without one the command only + * says there is no current client. The positive control runs only where the query is safe, and + * the client leaves right after it: before 3.6a that departure ends the daemon too. + */ + @Test + void clipboardQueryIsRefusedBelowItsReleaseAndTheDaemonStaysUp(Server server) throws Exception { + try (NativeClient client = NativeClient.attach(server.sessions().getFirst())) { + String name = server.clients().getFirst().name(); + List> spellings = List.of( + List.of("refresh-client", "-t", name, "-l"), + List.of("refresh-client", "-l"), + List.of("refresh", "-l"), + List.of("refresh-c", "-l"), + List.of("ref", "-lt", name), + List.of("refresh-client", "-l", "--")); + if (!server.version().atLeast(CLIPBOARD)) { + for (List command : spellings) { + assertThrows(UnsupportedFeatureException.class, () -> server.cmd(command), command.toString()); + assertThrows( + UnsupportedFeatureException.class, + () -> server.batch() + .add("list-sessions") + .add(command) + .run(), + "batch " + command); + assertTrue(server.isAlive(), command.toString()); + assertTrue(client.isAlive(), command.toString()); + } + assertThrows( + UnsupportedFeatureException.class, + () -> server.chain() + .then("list-sessions") + .then("refresh", "-l") + .run()); + // The valued flag swallows -l, so this is not a clipboard query and stays allowed. + assertTrue(server.cmd("refresh-client", "-t", name).succeeded()); + assertTrue(server.cmd("refresh", "-S", "-t", name).succeeded()); + } else { + assertTrue(server.cmd("refresh-client", "-t", name, "-l").succeeded()); + } + assertTrue(server.isAlive()); + assertTrue(client.isAlive()); + } + } + + /** + * The failure on 3.4 to 3.6 is a race between the query and its client leaving, so the query and + * the client's detach travel in one tmux invocation, which leaves the terminal no time to answer. + * Below 3.6a the batch is refused whole; from 3.6a it runs, and either way the daemon is read + * afterwards. Without the refusal the daemon of a 3.5 or 3.6 server does not survive this. + */ + @Test + void aClientLeavingAfterTheQueryCannotEndTheDaemon(Server server) throws Exception { + try (NativeClient client = NativeClient.attach(server.sessions().getFirst())) { + String name = server.clients().getFirst().name(); + if (server.version().atLeast(CLIPBOARD)) { + server.batch() + .add("refresh-client", "-t", name, "-l") + .add("detach-client", "-t", name) + .run(); + } else { + assertThrows( + UnsupportedFeatureException.class, + () -> server.batch() + .add("refresh-client", "-t", name, "-l") + .add("detach-client", "-t", name) + .run()); + assertTrue(client.isAlive(), "a refused batch must not have detached the client"); + server.cmd("detach-client", "-t", name); + } + assertTrue(Await.until(() -> !client.isAlive())); + } + // The server ends a moment after the client is gone, not while it leaves. + Thread.sleep(1000); + assertTrue(server.isAlive()); + assertTrue(server.cmd("list-sessions").succeeded()); + } + @Test void literalLayoutsKeepPresetsSerializedTreesAndIgnoredOperands(Server server) { String target = server.panes().getFirst().id().value(); diff --git a/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java b/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java index 8393c1861..4eeb73085 100644 --- a/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java +++ b/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java @@ -54,6 +54,18 @@ && flags(command, "ceFlpsStRmxyXYL").contains('p')) { && !intent.repairedBreak()) { throw new UnsupportedFeatureException( "literal break-pane and unnamed breaks are unreliable on tmux 3.7; use Pane.breakOut(name) or tmux 3.7a+"); + } else if (matches(verb, "refresh-client", "refresh", 3)) { + // -l asks the attached terminal for its clipboard; 3.3a ends the server on the reply, and + // 3.4 to 3.6 end it when the client leaves before the reply. + // The valued flags are those of the command's own argument table, so a value such as + // "-t -l" is never read as the flag. + if (!Capabilities.CLIPBOARD_QUERY.availableOn(version) + && flags(command, "ABCfFrt").contains('l')) { + throw new UnsupportedFeatureException( + "querying the clipboard with refresh-client -l", + Capabilities.CLIPBOARD_QUERY.since(), + version); + } } else if (matches(verb, "new-session", "new", 3)) { names(version, command, "cefFnstxy", "ns"); } else if (matches(verb, "new-window", "neww", 5)) { diff --git a/libtmux/src/main/java/io/github/libtmux/internal/support/Capabilities.java b/libtmux/src/main/java/io/github/libtmux/internal/support/Capabilities.java index 69ad06e3d..3ea741689 100644 --- a/libtmux/src/main/java/io/github/libtmux/internal/support/Capabilities.java +++ b/libtmux/src/main/java/io/github/libtmux/internal/support/Capabilities.java @@ -198,6 +198,19 @@ public boolean affects(TmuxVersion running) { List.of("Pane.capture", "CaptureSpec.Builder.withHyperlinks", "CaptureSpec.Builder.withLineNumbers"), List.of(new Command("capture-pane", List.of("H", "L")))); + public static final Capability CLIPBOARD_QUERY = new Capability( + "clipboard-query", + "Clipboard query", + "Ask a client's terminal for its clipboard with refresh-client -l. On 3.3a the server exits when a" + + " client attached from xterm or tmux-256color receives it, and on 3.4 to 3.6 it exits" + + " when the client leaves before its terminal answers.", + new TmuxVersion(3, 6, "a"), + List.of(), + Handling.REFUSE, + REFUSAL, + List.of("Server.cmd", "Server.run", "Server.batch"), + List.of(new Command("refresh-client", List.of("l")))); + public static final Capability CAPTURE_TRIM_BLANK_CELLS = new Capability( "capture-trim-blank-cells", "Trim blank cells from a capture", @@ -267,7 +280,8 @@ public boolean affects(TmuxVersion running) { LAYOUT_JSON, CAPTURE_TRIM_BLANK_CELLS, CAPTURE_MODE_SCREEN, - CAPTURE_LINE_FLAGS); + CAPTURE_LINE_FLAGS, + CLIPBOARD_QUERY); private static final List WORKAROUNDS = List.of( RUN_SHELL_OUTPUT_IN_COPY_MODE, diff --git a/libtmux/src/test/java/io/github/libtmux/internal/support/CapabilitiesTest.java b/libtmux/src/test/java/io/github/libtmux/internal/support/CapabilitiesTest.java index 4a0e1b349..ea736e911 100644 --- a/libtmux/src/test/java/io/github/libtmux/internal/support/CapabilitiesTest.java +++ b/libtmux/src/test/java/io/github/libtmux/internal/support/CapabilitiesTest.java @@ -131,6 +131,34 @@ void namedBufferCommandsAreRefusedBelowTheirReleaseOnly() { TmuxFeatures.require(Capabilities.BUFFER_RENAME.since(), TmuxFeatures.Intent.literal(renames)); } + @Test + void clipboardQueryIsRefusedUnderEveryNameBelowItsRelease() { + assertEquals(TmuxVersion.parse("3.6a"), Capabilities.CLIPBOARD_QUERY.since()); + // 3.3a ends the server on the reply; 3.4 to 3.6 end it when the client leaves unanswered. + for (String release : List.of("3.3a", "3.4", "3.5a", "3.6")) { + TmuxVersion before = TmuxVersion.parse(release); + clipboardQueryIsRefusedOn(before); + } + } + + private static void clipboardQueryIsRefusedOn(TmuxVersion before) { + for (List command : List.of( + List.of("refresh-client", "-l"), + List.of("refresh", "-l"), + List.of("refresh-c", "-t", "/dev/pts/1", "-l"), + List.of("ref", "-lt/dev/pts/1"), + List.of("refresh-client", "-Sl"))) { + assertThrows( + UnsupportedFeatureException.class, + () -> TmuxFeatures.require(before, TmuxFeatures.Intent.literal(List.of(command))), + command.toString()); + TmuxFeatures.require(Capabilities.CLIPBOARD_QUERY.since(), TmuxFeatures.Intent.literal(List.of(command))); + } + // A value that merely looks like the flag is not the flag. + TmuxFeatures.require(before, TmuxFeatures.Intent.literal(List.of(List.of("refresh-client", "-t", "-l")))); + TmuxFeatures.require(before, TmuxFeatures.Intent.literal(List.of(List.of("refresh-client", "-S")))); + } + @Test void literalPercentSplitAndLiteralBreakAreRefusedOnTheirExactReleases() { List> split = List.of(List.of("split-window", "-p", "30")); From ef555420ccba5cba92480776d426461828192ebc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Oct 2026 00:20:18 -0500 Subject: [PATCH 14/18] Waits(fix): End a text wait on a gone pane with the output so far why: Pane.awaitText on a pane that was killed mid-wait threw CommandRejectedException ("can't find pane") from the failed read, so a caller had no failed-wait answer and lost what the pane had printed. Measured on tmux 3.3a, 3.7c and 3.8. A killed server already answered SERVER_GONE but also carried no output. what: - Add TextOutcome.PANE_GONE, returned when a read fails, the server still answers and no longer lists the pane. The error text is not read, because tmux words it differently across releases, and no timer or client count decides it. - Add Pane.watchText, with the same arguments as awaitText, returning a TextWait of the outcome and the pane's rows at the last good read, wrapped rows rejoined. awaitText delegates to it. Both are additive except that awaitText no longer throws for a gone pane. - Pane.await keeps raising for a gone pane: a caller's own condition has no failed-wait answer. - Test on real tmux with a pane killed and a server killed mid-wait, each with and without a second control client stopped on the socket. - Make those tests wait until the wait has read the pane after the output appeared, counting its reads, instead of a fixed delay that a slow runner outran. - Document the outcome on awaitText and the Kotlin and Scala wrappers, and in MIGRATION.md. --- MIGRATION.md | 5 + docs/reference/operations.md | 2 + .../it/DeadPaneWaitIntegrationTest.java | 174 ++++++++++++++++++ .../io/github/libtmux/it/StoppedClient.java | 68 +++++++ .../kotlin/io/github/libtmux/kotlin/Pane.kt | 2 +- .../libtmux/scaladsl/cats/Handles.scala | 4 +- .../io/github/libtmux/scaladsl/Pane.scala | 4 +- .../src/main/java/io/github/libtmux/Pane.java | 35 +++- .../main/java/io/github/libtmux/PaneWait.java | 90 ++++++--- .../java/io/github/libtmux/TextOutcome.java | 8 + .../main/java/io/github/libtmux/TextWait.java | 24 +++ 11 files changed, 384 insertions(+), 32 deletions(-) create mode 100644 integration-tests/src/test/java/io/github/libtmux/it/DeadPaneWaitIntegrationTest.java create mode 100644 integration-tests/src/test/java/io/github/libtmux/it/StoppedClient.java create mode 100644 libtmux/src/main/java/io/github/libtmux/TextWait.java diff --git a/MIGRATION.md b/MIGRATION.md index 8266f9565..eb25faaea 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -536,6 +536,11 @@ screen wait can end a way a channel never does: | `TIMED_OUT` | `TextOutcome.TIMED_OUT` | | `SERVER_GONE` | `TextOutcome.SERVER_GONE` | +A pane killed mid-wait is `TextOutcome.PANE_GONE` instead of a +`CommandRejectedException` from the failed read. `Pane.watchText` takes the +same arguments and also returns the output so far as a `TextWait`. A +condition passed to `Pane.await` still raises for a pane that is gone. + `PRESENT_AT_ENTRY` says only that the text was there on the first look. It may be output from a command that finished before the wait read, or it may have been on the pane for an hour; a screen cannot tell those apart. What it replaces is diff --git a/docs/reference/operations.md b/docs/reference/operations.md index 1d086deec..4e9a8c053 100644 --- a/docs/reference/operations.md +++ b/docs/reference/operations.md @@ -96,6 +96,8 @@ Generated from `@Operation`-annotated methods by the operation-catalog Doclet; d | `io.github.libtmux.Pane` | `noteTyped(java.lang.String)` | MUTATION | Notes text that reached this pane without going through this handle, so a wait discounts its echo the way it discounts text sent through {@link #sendLiteral}. | | `io.github.libtmux.Pane` | `awaitText(java.lang.String, java.time.Duration)` | WAIT | Waits until this pane's text contains something, and says why the wait ended. | | `io.github.libtmux.Pane` | `awaitText(java.lang.String, java.time.Duration, java.time.Duration)` | WAIT | As {@link #awaitText(String, Duration)}, looking every {@code every} rather than every 50 ms. | +| `io.github.libtmux.Pane` | `watchText(java.lang.String, java.time.Duration)` | WAIT | As {@link #awaitText(String, Duration)}, and also returns what the pane showed. | +| `io.github.libtmux.Pane` | `watchText(java.lang.String, java.time.Duration, java.time.Duration)` | WAIT | As {@link #watchText(String, Duration)}, looking every {@code every} rather than every 50 ms. | | `io.github.libtmux.Pane` | `run(java.lang.String, java.time.Duration)` | WAIT | Runs a shell command in this pane to its end, and answers with its exit status and output. | | `io.github.libtmux.Pane` | `await(java.util.function.Predicate, java.time.Duration)` | WAIT | Waits until a condition holds for this pane as tmux reports it, and says why the wait ended. | | `io.github.libtmux.Pane` | `await(java.util.function.Predicate, java.time.Duration, java.time.Duration)` | WAIT | As {@link #await(Predicate, Duration)}, looking every {@code every} rather than every 50 ms. | diff --git a/integration-tests/src/test/java/io/github/libtmux/it/DeadPaneWaitIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/DeadPaneWaitIntegrationTest.java new file mode 100644 index 000000000..e5b6aeaf5 --- /dev/null +++ b/integration-tests/src/test/java/io/github/libtmux/it/DeadPaneWaitIntegrationTest.java @@ -0,0 +1,174 @@ +package io.github.libtmux.it; + +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 io.github.libtmux.Pane; +import io.github.libtmux.Server; +import io.github.libtmux.TextOutcome; +import io.github.libtmux.TextWait; +import io.github.libtmux.exception.LibTmuxException; +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.time.Duration; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * A wait for text whose pane, or whose server, goes away underneath it. + * + *

Each case runs with and without a second control client stopped on the socket. tmux keeps the + * daemon until every client leaves, so an answer that came from counting clients or from a grace + * timer would differ between the two; the answer here is the server's own, now. + */ +@ExtendWith(TmuxExtension.class) +final class DeadPaneWaitIntegrationTest { + + private static final String PRINTED = "printed-before-the-end"; + + private static Pane victim(Server server) { + return server.newSession(session -> + session.named("victim").running("sh", "-c", "sleep 1; echo " + PRINTED + "; sleep 300")) + .windows() + .get(0) + .panes() + .get(0); + } + + /** + * Counts the pane reads the wait under test completes, so a test can tell that the wait has read + * the output instead of guessing how long that takes on a slow runner. + */ + private static final class Reads implements AutoCloseable { + private final ProcessTransport processes = new ProcessTransport(); + private final AtomicInteger completed = new AtomicInteger(); + + Pane over(Server server, Pane pane) { + TmuxTransport counting = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + CommandResult result = processes.execute(request); + if (request.commands().stream() + .anyMatch(command -> command.stream().anyMatch(word -> word.contains("capture-pane")))) { + completed.incrementAndGet(); + } + return result; + } + + @Override + public void close() {} + }; + return Server.using(server.config(), counting).pane(pane.id()).orElseThrow(); + } + + @Override + public void close() { + processes.close(); + } + } + + /** Waits for the output to be on the pane, then for the wait to have read the pane after that. */ + private static void awaitPrinted(Pane pane, Reads reads) throws Exception { + assertTrue(Await.until(() -> pane.capture().stream().anyMatch(line -> line.contains(PRINTED))), "no output"); + int seen = reads.completed.get(); + assertTrue(Await.until(() -> reads.completed.get() >= seen + 3), "the wait did not read the pane again"); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void aPaneKilledMidWaitIsAFailedWaitWithTheOutputSoFar(boolean withStoppedClient, Server server) throws Exception { + server.newSession(session -> session.named("outlives")); + Pane pane = victim(server); + ExecutorService waiter = Executors.newSingleThreadExecutor(); + try (StoppedClient _ = StoppedClient.attach(server, "outlives", withStoppedClient); + Reads reads = new Reads()) { + Pane watched = reads.over(server, pane); + Future waiting = waiter.submit(() -> watched.watchText("never-printed", Duration.ofSeconds(60))); + awaitPrinted(pane, reads); + long killed = System.nanoTime(); + pane.kill(); + + TextWait wait = waiting.get(15, TimeUnit.SECONDS); + + assertEquals(TextOutcome.PANE_GONE, wait.outcome()); + assertTrue(wait.output().contains(PRINTED), "output so far: " + wait.output()); + assertTrue(System.nanoTime() - killed < TimeUnit.SECONDS.toNanos(10), "a timer decided this"); + } finally { + waiter.shutdownNow(); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void awaitTextReportsAKilledPaneAsAnOutcomeToo(boolean withStoppedClient, Server server) throws Exception { + server.newSession(session -> session.named("outlives")); + Pane pane = victim(server); + ExecutorService waiter = Executors.newSingleThreadExecutor(); + try (StoppedClient _ = StoppedClient.attach(server, "outlives", withStoppedClient); + Reads reads = new Reads()) { + Pane watched = reads.over(server, pane); + Future waiting = + waiter.submit(() -> watched.awaitText("never-printed", Duration.ofSeconds(60))); + awaitPrinted(pane, reads); + pane.kill(); + + assertEquals(TextOutcome.PANE_GONE, waiting.get(15, TimeUnit.SECONDS)); + } finally { + waiter.shutdownNow(); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void aServerKilledMidWaitIsServerGoneWithTheOutputSoFar(boolean withStoppedClient, Server server) throws Exception { + server.newSession(session -> session.named("outlives")); + Pane pane = victim(server); + ExecutorService waiter = Executors.newSingleThreadExecutor(); + try (StoppedClient _ = StoppedClient.attach(server, "outlives", withStoppedClient); + Reads reads = new Reads()) { + Pane watched = reads.over(server, pane); + Future waiting = waiter.submit(() -> watched.watchText("never-printed", Duration.ofSeconds(60))); + awaitPrinted(pane, reads); + long killed = System.nanoTime(); + server.cmd("kill-server"); + + TextWait wait = waiting.get(15, TimeUnit.SECONDS); + + assertEquals(TextOutcome.SERVER_GONE, wait.outcome()); + assertTrue(wait.output().contains(PRINTED), "output so far: " + wait.output()); + assertTrue(System.nanoTime() - killed < TimeUnit.SECONDS.toNanos(10), "a timer decided this"); + } finally { + waiter.shutdownNow(); + } + } + + /** A predicate the caller wrote has no failed-wait answer for a pane, so a gone pane still raises. */ + @org.junit.jupiter.api.Test + void aCallersOwnConditionStillRaisesForAGonePane(Server server) throws Exception { + server.newSession(session -> session.named("outlives")); + Pane pane = victim(server); + ExecutorService waiter = Executors.newSingleThreadExecutor(); + try { + Future waiting = waiter.submit( + () -> pane.await(fresh -> fresh.capture().contains("never-printed"), Duration.ofSeconds(60))); + Thread.sleep(500); + pane.kill(); + + var failure = assertThrows( + java.util.concurrent.ExecutionException.class, () -> waiting.get(15, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof LibTmuxException, String.valueOf(failure.getCause())); + } finally { + waiter.shutdownNow(); + } + } +} diff --git a/integration-tests/src/test/java/io/github/libtmux/it/StoppedClient.java b/integration-tests/src/test/java/io/github/libtmux/it/StoppedClient.java new file mode 100644 index 000000000..443c7b24b --- /dev/null +++ b/integration-tests/src/test/java/io/github/libtmux/it/StoppedClient.java @@ -0,0 +1,68 @@ +package io.github.libtmux.it; + +import io.github.libtmux.Server; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.jspecify.annotations.Nullable; + +/** + * A second control client, attached and then stopped with SIGSTOP, or nothing at all. + * + *

tmux leaves the daemon running after {@code kill-server} until every client has left, so a wait + * that decided the server was gone by counting clients or by a timer would answer differently with + * one stopped on the socket. Each case runs both ways. + */ +final class StoppedClient implements AutoCloseable { + + private final @Nullable Process process; + + private StoppedClient(@Nullable Process process) { + this.process = process; + } + + /** Attaches and stops a client to {@code session} when {@code wanted}; otherwise does nothing. */ + static StoppedClient attach(Server server, String session, boolean wanted) + throws IOException, InterruptedException { + if (!wanted) { + return new StoppedClient(null); + } + List argv = new ArrayList<>(server.config().endpointCommand()); + argv.addAll(List.of("-C", "attach-session", "-t", session)); + ProcessBuilder builder = new ProcessBuilder(argv); + builder.environment().remove("TMUX"); + Process client = builder.redirectErrorStream(true).start(); + StoppedClient stopped = new StoppedClient(client); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (server.clients().isEmpty() && System.nanoTime() < deadline) { + Thread.sleep(20); + } + if (server.clients().isEmpty()) { + stopped.close(); + throw new IOException("the control client never attached"); + } + signal("STOP", client.pid()); + return stopped; + } + + private static void signal(String name, long pid) throws IOException, InterruptedException { + new ProcessBuilder("kill", "-" + name, Long.toString(pid)).start().waitFor(); + } + + @Override + public void close() throws IOException { + Process client = process; + if (client == null) { + return; + } + try { + signal("CONT", client.pid()); + client.getOutputStream().close(); + if (!client.waitFor(5, TimeUnit.SECONDS)) client.destroyForcibly(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + client.destroyForcibly(); + } + } +} diff --git a/libtmux-kotlin/src/main/kotlin/io/github/libtmux/kotlin/Pane.kt b/libtmux-kotlin/src/main/kotlin/io/github/libtmux/kotlin/Pane.kt index f503175f1..2b9de3952 100644 --- a/libtmux-kotlin/src/main/kotlin/io/github/libtmux/kotlin/Pane.kt +++ b/libtmux-kotlin/src/main/kotlin/io/github/libtmux/kotlin/Pane.kt @@ -82,7 +82,7 @@ public class Pane internal constructor(internal val java: JavaPane, public val s * @param timeout how long to keep looking, finite and not negative * @param every how long to leave between looks, at least 10 ms * @param dispatcher where the blocking wait runs - * @return why the wait ended + * @return why the wait ended; a pane killed mid-wait is [TextOutcome.PANE_GONE], not an exception */ public suspend fun awaitText( text: String, diff --git a/libtmux-scala-cats/src/main/scala/io/github/libtmux/scaladsl/cats/Handles.scala b/libtmux-scala-cats/src/main/scala/io/github/libtmux/scaladsl/cats/Handles.scala index 4706d8212..6e923236f 100644 --- a/libtmux-scala-cats/src/main/scala/io/github/libtmux/scaladsl/cats/Handles.scala +++ b/libtmux-scala-cats/src/main/scala/io/github/libtmux/scaladsl/cats/Handles.scala @@ -92,7 +92,9 @@ object Pane { self.server.execution ) - /** Waits until `text` appears, or `timeout` passes first. */ + /** Waits until `text` appears, or `timeout` passes first. A pane killed + * mid-wait answers `TextOutcome.PANE_GONE` rather than raising. + */ def awaitText( text: String, timeout: scala.concurrent.duration.FiniteDuration diff --git a/libtmux-scala/src/main/scala/io/github/libtmux/scaladsl/Pane.scala b/libtmux-scala/src/main/scala/io/github/libtmux/scaladsl/Pane.scala index 6727a8326..9a89e3c78 100644 --- a/libtmux-scala/src/main/scala/io/github/libtmux/scaladsl/Pane.scala +++ b/libtmux-scala/src/main/scala/io/github/libtmux/scaladsl/Pane.scala @@ -41,7 +41,9 @@ object Pane { // Each blocks its thread; interrupting that thread cancels the wait with InterruptedException, // as the Java method declares. - /** Waits until `text` appears, or `timeout` passes first. */ + /** Waits until `text` appears, or `timeout` passes first. A pane killed + * mid-wait answers `TextOutcome.PANE_GONE` rather than raising. + */ def awaitText(text: String, timeout: FiniteDuration): TextOutcome = self.asJava.awaitText(text, timeout.toJava) diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index 4beb09049..17696aac1 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -372,8 +372,9 @@ public void noteTyped(String text) { * @param text the text to wait for, matched anywhere in a captured line * @param timeout how long to keep looking * @return why the wait ended, which tells text that appeared from text that was already there - * @throws LibTmuxException if a read fails while the server is still answering — most often - * because this pane was killed, which is not a timeout + * @throws LibTmuxException if a read fails for a reason of the caller's while the server is still + * answering and still lists this pane. A pane killed mid-wait is not one: that ends the wait + * as {@link TextOutcome#PANE_GONE}, and {@link #watchText} also returns what it had shown * @throws InterruptedException if the waiting thread is interrupted, which is a cancellation * rather than a timeout and so is not reported as one */ @@ -394,6 +395,36 @@ public TextOutcome awaitText(String text, Duration timeout) throws InterruptedEx */ @Operation(Kind.WAIT) public TextOutcome awaitText(String text, Duration timeout, Duration every) throws InterruptedException { + return watchText(text, timeout, every).outcome(); + } + + /** + * As {@link #awaitText(String, Duration)}, and also returns what the pane showed. + * + *

The output is the pane at the wait's last successful read, so a pane that was killed, or a + * server that went away, mid-wait still leaves the caller what had been printed. Ending as + * {@link TextOutcome#PANE_GONE} or {@link TextOutcome#SERVER_GONE} is an answer, not an + * exception, and neither is decided by a timer: the server answers a probe now or it does not, + * and lists the pane now or does not, with or without another client attached to it. + * + * @return the outcome and the output so far + * @throws LibTmuxException if a read fails for a reason of the caller's while the server is still + * answering and still lists this pane + * @throws InterruptedException if the waiting thread is interrupted + */ + @Operation(Kind.WAIT) + public TextWait watchText(String text, Duration timeout) throws InterruptedException { + return watchText(text, timeout, PaneWait.POLL); + } + + /** + * As {@link #watchText(String, Duration)}, looking every {@code every} rather than every 50 ms. + * + * @param every how long to leave between looks, at least 10 ms + * @throws IllegalArgumentException if {@code every} is shorter than 10 ms + */ + @Operation(Kind.WAIT) + public TextWait watchText(String text, Duration timeout, Duration every) throws InterruptedException { Objects.requireNonNull(text, "text"); return PaneWait.awaitText(this, text, timeout, every); } diff --git a/libtmux/src/main/java/io/github/libtmux/PaneWait.java b/libtmux/src/main/java/io/github/libtmux/PaneWait.java index 22538e932..eff4307ee 100644 --- a/libtmux/src/main/java/io/github/libtmux/PaneWait.java +++ b/libtmux/src/main/java/io/github/libtmux/PaneWait.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Objects; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Predicate; /** Polls a pane on a timer for {@link Pane#awaitText} and {@link Pane#await}. */ @@ -33,14 +34,17 @@ final class PaneWait { private PaneWait() {} - /** {@link Pane#awaitText(String, Duration, Duration)}'s implementation. */ - static TextOutcome awaitText(Pane pane, String text, Duration timeout, Duration every) throws InterruptedException { + /** {@link Pane#watchText(String, Duration, Duration)}'s implementation. */ + static TextWait awaitText(Pane pane, String text, Duration timeout, Duration every) throws InterruptedException { TypedText typed = TypedText.in(pane); boolean[] reading = {true, false}; - WakeReason ended = awaitCondition( + AtomicReference> seen = new AtomicReference<>(List.of()); + Ended ended = awaitCondition( pane, bounded -> { - boolean found = shown(bounded, typed).stream().anyMatch(line -> line.contains(text)); + List lines = shown(bounded, typed); + seen.set(lines); + boolean found = lines.stream().anyMatch(line -> line.contains(text)); if (reading[0]) { reading[0] = false; reading[1] = found; @@ -48,18 +52,44 @@ static TextOutcome awaitText(Pane pane, String text, Duration timeout, Duration return found; }, timeout, - every); - return switch (ended) { - case SIGNALLED -> reading[1] ? TextOutcome.PRESENT_AT_ENTRY : TextOutcome.APPEARED; - case TIMED_OUT -> TextOutcome.TIMED_OUT; - case SERVER_GONE -> TextOutcome.SERVER_GONE; - }; + every, + true); + TextOutcome outcome = + switch (ended) { + case SIGNALLED -> reading[1] ? TextOutcome.PRESENT_AT_ENTRY : TextOutcome.APPEARED; + case TIMED_OUT -> TextOutcome.TIMED_OUT; + case SERVER_GONE -> TextOutcome.SERVER_GONE; + case PANE_GONE -> TextOutcome.PANE_GONE; + }; + return new TextWait(outcome, withoutTrailingBlanks(seen.get())); + } + + private static List withoutTrailingBlanks(List lines) { + int end = lines.size(); + while (end > 0 && lines.get(end - 1).isBlank()) { + end--; + } + return List.copyOf(lines.subList(0, end)); } /** {@link Pane#await(Predicate, Duration, Duration)}'s implementation. */ static WakeReason await(Pane pane, Predicate settled, Duration timeout, Duration every) throws InterruptedException { - return awaitCondition(pane, bounded -> settled.test(bounded.refresh().through(pane.server())), timeout, every); + return switch (awaitCondition( + pane, bounded -> settled.test(bounded.refresh().through(pane.server())), timeout, every, false)) { + case SIGNALLED -> WakeReason.SIGNALLED; + case TIMED_OUT -> WakeReason.TIMED_OUT; + case SERVER_GONE -> WakeReason.SERVER_GONE; + case PANE_GONE -> throw new IllegalStateException("a wait that rethrows a dead pane ended as one"); + }; + } + + /** How a wait ended, before the public wait maps it to its own outcome type. */ + private enum Ended { + SIGNALLED, + TIMED_OUT, + SERVER_GONE, + PANE_GONE } /** @@ -76,7 +106,8 @@ static WakeReason await(Pane pane, Predicate settled, Duration timeout, Du * pane" the same way, so that one gets a second look to tell {@link WakeReason#SERVER_GONE} from a * failure that belongs to the caller. */ - private static WakeReason awaitCondition(Pane pane, Predicate poll, Duration timeout, Duration every) + private static Ended awaitCondition( + Pane pane, Predicate poll, Duration timeout, Duration every, boolean paneGoneIsAnAnswer) throws InterruptedException { Objects.requireNonNull(timeout, "timeout"); Objects.requireNonNull(every, "every"); @@ -99,42 +130,47 @@ private static WakeReason awaitCondition(Pane pane, Predicate poll, Durati first = false; try { if (poll.test(pane.through(pane.server().within(budget)))) { - return WakeReason.SIGNALLED; + return Ended.SIGNALLED; } } catch (DispatchException.TimedOut expired) { - return WakeReason.TIMED_OUT; + return Ended.TIMED_OUT; } catch (LibTmuxException unreadable) { - return afterFailedRead(pane, unreadable); + return afterFailedRead(pane, unreadable, paneGoneIsAnAnswer); } long remaining = deadline - System.nanoTime(); if (remaining <= 0) { - return WakeReason.TIMED_OUT; + return Ended.TIMED_OUT; } TimeUnit.NANOSECONDS.sleep(Math.min(every.toNanos(), remaining)); if (System.nanoTime() >= deadline) { - return WakeReason.TIMED_OUT; + return Ended.TIMED_OUT; } } } /** - * Tells a server that went away from a read that failed for a reason of the caller's. + * Tells a server that went away, and a pane that did, from a read that failed for a reason of the + * caller's. * *

A probe that cannot get an answer in time proves nothing either way, so the original failure - * is what the caller sees, carrying the probe's. + * is what the caller sees, carrying the probe's. A pane counts as gone only when the server + * answers and no longer lists it; the error text is not read, because tmux words it differently + * across releases. */ - private static WakeReason afterFailedRead(Pane pane, LibTmuxException unreadable) { - boolean alive; + private static Ended afterFailedRead(Pane pane, LibTmuxException unreadable, boolean paneGoneIsAnAnswer) { try { - alive = pane.server().isAlive(SHORTEST_READ); + if (!pane.server().isAlive(SHORTEST_READ)) { + return Ended.SERVER_GONE; + } + if (paneGoneIsAnAnswer + && pane.server().within(SHORTEST_READ).panes().stream() + .noneMatch(listed -> listed.id().equals(pane.id()))) { + return Ended.PANE_GONE; + } } catch (DispatchException.TimedOut unanswered) { unreadable.addSuppressed(unanswered); - throw unreadable; - } - if (alive) { - throw unreadable; } - return WakeReason.SERVER_GONE; + throw unreadable; } /** diff --git a/libtmux/src/main/java/io/github/libtmux/TextOutcome.java b/libtmux/src/main/java/io/github/libtmux/TextOutcome.java index ece52bb8e..be2eb593f 100644 --- a/libtmux/src/main/java/io/github/libtmux/TextOutcome.java +++ b/libtmux/src/main/java/io/github/libtmux/TextOutcome.java @@ -34,6 +34,14 @@ public enum TextOutcome { PRESENT_AT_ENTRY, /** The deadline passed with the text absent. */ TIMED_OUT, + /** + * The pane was killed, or its process exited and closed it, while the wait was running, and the + * server is still answering. + * + *

The wait cannot succeed and no timeout will change that. {@link Pane#watchText} carries what + * the pane had shown. + */ + PANE_GONE, /** The server went away underneath the wait, so nothing it was guarding can be relied on. */ SERVER_GONE } diff --git a/libtmux/src/main/java/io/github/libtmux/TextWait.java b/libtmux/src/main/java/io/github/libtmux/TextWait.java new file mode 100644 index 000000000..0ddee9d04 --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/TextWait.java @@ -0,0 +1,24 @@ +package io.github.libtmux; + +import java.util.List; +import java.util.Objects; +import kotlin.annotations.jvm.ReadOnly; + +/** + * How a wait for text on a pane ended, and what the pane had shown by then. + * + *

{@link Pane#awaitText} answers with the outcome alone. This carries the screen as well, which a + * wait that ends because the pane or the server went away needs most: the output so far is all there + * will ever be. + * + * @param outcome why the wait ended + * @param output the rows of the pane at its last successful read, wrapped rows rejoined, the echo of + * what this library typed removed and trailing blank rows dropped; empty when the pane was gone + * before the first read + */ +public record TextWait(TextOutcome outcome, @ReadOnly List output) { + public TextWait { + Objects.requireNonNull(outcome, "outcome"); + output = List.copyOf(output); + } +} From ab360f3c318ecdcfd0266d168e166466c272e581 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Oct 2026 00:20:19 -0500 Subject: [PATCH 15/18] Mcp(fix): Answer a wait for a gone pane or server with its output why: wait_for_text on a pane killed mid-wait returned a tool error ("could not read pane content and position as one batch") instead of an answer, and so did a killed server instead of the documented SERVER_GONE; run_shell_command on a killed pane ran to its whole timeout, and on a killed server returned no output. Measured on tmux 3.3a and 3.8. what: - Add Ends, which classifies a failed read as SERVER_GONE (the server no longer answers) or PANE_GONE (it answers and no longer lists the pane), and rethrows anything else. The server's answer decides it, listed through list-panes so no after-display-message hook runs. - wait_for_text ends as PANE_GONE or SERVER_GONE with the lines seen so far. - run_shell_command watches the pane while it blocks (PaneWatch) and signals its own private channel when the pane is gone. A bounded liveness check runs on a 250 ms interval and tmux's answer to it decides the outcome, so a killed pane ends the call within about a quarter second with PANE_GONE and the output of the last look; a killed server keeps that output too. The check reads through list-panes and capture-pane, never display-message. - matched_line for a match split across wrapped rows is now the rows that carry the match, not the whole region around them. - Test both outcomes on real tmux, with and without a second control client stopped on the socket, and that the check fires no after-display-message hook while a display-message does; with the check reading through the batch again that test fails. Document PANE_GONE in the README. --- libtmux-mcp/README.md | 15 +- .../main/java/io/github/libtmux/mcp/Ends.java | 47 ++++++ .../java/io/github/libtmux/mcp/PaneWatch.java | 98 ++++++++++++ .../github/libtmux/mcp/RunningCommands.java | 51 ++++-- .../java/io/github/libtmux/mcp/Screen.java | 59 ++++++- .../io/github/libtmux/mcp/WaitingForText.java | 147 ++++++++++-------- .../java/io/github/libtmux/mcp/DyingPane.java | 50 ++++++ .../libtmux/mcp/RunningCommandsTest.java | 133 ++++++++++++++++ .../io/github/libtmux/mcp/StoppedClient.java | 68 ++++++++ .../libtmux/mcp/WaitingForTextTest.java | 73 +++++++++ 10 files changed, 663 insertions(+), 78 deletions(-) create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/Ends.java create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneWatch.java create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/DyingPane.java create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/StoppedClient.java diff --git a/libtmux-mcp/README.md b/libtmux-mcp/README.md index f990dba26..e2eae2c4e 100644 --- a/libtmux-mcp/README.md +++ b/libtmux-mcp/README.md @@ -246,7 +246,11 @@ One call sends the command, waits, and returns the output and the exit status: - `SIGNALLED`: the command finished. Only then is `exit_status` meaningful. - `TIMED_OUT`: it was still running at the deadline. -- `SERVER_GONE`: tmux itself died underneath the wait. +- `PANE_GONE`: the pane was killed or exited while the command ran, so it did + not finish. `output` holds what it had printed; the call does not wait out + its timeout. +- `SERVER_GONE`: tmux itself died underneath the wait. `output` holds what the + last look at the pane showed. What to know about the command: @@ -277,6 +281,15 @@ For a dev server, a daemon, or someone else's build, always pass `stop`: eight characters. - Text already on the screen when a cursorless call starts is not a fresh match. The outcome is `PRESENT_AT_ENTRY`, not `MATCHED`. +- A pane killed mid-wait ends the call as `PANE_GONE`, and a killed server as + `SERVER_GONE`. Both carry the output seen so far, and a second client left + attached to the dying server does not change the answer. A bounded liveness + check runs on an interval while the wait blocks, so a killed pane ends + [`run_shell_command`][tool-run-shell-command] within about a quarter + second; tmux's answer to that check, never the elapsed time, decides the + outcome. +- A match split across a wrapped row is found, and `matched_line` holds only + the rows that carry it. ### [`capture_since`][tool-capture-since]: watch across turns diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Ends.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Ends.java new file mode 100644 index 000000000..1e4177195 --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Ends.java @@ -0,0 +1,47 @@ +package io.github.libtmux.mcp; + +import io.github.libtmux.Pane; +import io.github.libtmux.exception.CommandRejectedException; +import io.github.libtmux.exception.LibTmuxException; +import io.github.libtmux.transport.CommandResult; + +/** + * Tells why a read of a pane failed, so a wait can answer with it instead of with the failure. + * + *

A pane killed under a wait, and a server killed under it, both come back from tmux as a refused + * command. Neither is the caller's mistake and neither is worth a retry: the wait is over, and what + * it had read is the answer. Nothing here waits or counts down; the server either answers a probe + * now or it does not, and a pane is either listed now or it is not. + */ +enum Ends { + /** The server no longer answers. */ + SERVER_GONE, + /** The server answers and the pane is no longer in it. */ + PANE_GONE; + + /** + * Why a read failed. + * + * @param unread the failure of the read + * @return the end the failure means + * @throws LibTmuxException the failure itself, when the server still answers and still lists the + * pane, so it belongs to the caller + */ + static Ends of(Pane pane, LibTmuxException unread) { + // list-panes answers both questions and, unlike display-message, runs no + // after-display-message hook, so asking costs the user's hooks nothing. + CommandResult listing; + try { + listing = pane.server().cmd("list-panes", "-a", "-F", "#{pane_id}"); + } catch (CommandRejectedException refused) { + return SERVER_GONE; + } + if (!listing.succeeded()) { + return SERVER_GONE; + } + if (!listing.stdout().contains(pane.id().value())) { + return PANE_GONE; + } + throw unread; + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneWatch.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneWatch.java new file mode 100644 index 000000000..aaa6c1a09 --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneWatch.java @@ -0,0 +1,98 @@ +package io.github.libtmux.mcp; + +import io.github.libtmux.Pane; +import io.github.libtmux.exception.LibTmuxException; +import java.time.Duration; +import org.jspecify.annotations.Nullable; + +/** + * Watches the pane of a running command for its end, and keeps what it last read. + * + *

A command's end is signalled by a trap in the pane's own shell. A pane killed under it never + * runs that trap, so the signal never comes and the wait would run to its timeout with the pane long + * gone. This looks at the pane while the wait blocks, and when the pane is gone it signals the same + * channel the trap would have, so the blocked wait ends within a look. A signal sent before anything + * waits is remembered by tmux, so there is no window in which it can be lost. + * + *

The looks run on an interval, and each is a question the server answers now: tmux's answer, not + * the interval, decides whether the pane is gone. The last good look is also what a server killed + * mid-wait leaves behind, since nothing can be read from it afterwards. A look reads through + * {@code list-panes} and {@code capture-pane}, never {@code display-message}, so the user's + * {@code after-display-message} hook does not run on every look. + */ +final class PaneWatch implements AutoCloseable { + + /** How often the pane is looked at while a command runs. */ + private static final Duration LOOK = Duration.ofMillis(250); + + private final Pane pane; + private final Cursor from; + private final int budget; + private final Runnable wake; + private final Thread thread; + private volatile Screen.@Nullable Fresh last; + private volatile @Nullable Ends ended; + + private PaneWatch(Pane pane, Cursor from, int budget, Runnable wake) { + this.pane = pane; + this.from = from; + this.budget = budget; + this.wake = wake; + this.thread = Thread.ofVirtual().name("libtmux-run-watch").unstarted(this::watch); + } + + /** + * Starts watching. + * + * @param from the cursor the command started after + * @param wake what ends the blocked wait when the pane is found gone + */ + static PaneWatch over(Pane pane, Cursor from, int budget, Runnable wake) { + PaneWatch watch = new PaneWatch(pane, from, budget, wake); + watch.thread.start(); + return watch; + } + + /** What the pane showed at the last look that worked, or null before any. */ + Screen.@Nullable Fresh last() { + return last; + } + + /** Why the pane stopped answering, or null while it has not. */ + @Nullable + Ends ended() { + return ended; + } + + private void watch() { + try { + while (true) { + Thread.sleep(LOOK); + try { + last = Screen.sinceWithoutHooks(pane, from, budget); + } catch (LibTmuxException unread) { + Ends why; + try { + why = Ends.of(pane, unread); + } catch (LibTmuxException callers) { + continue; + } + ended = why; + if (why == Ends.PANE_GONE) { + wake.run(); + } + return; + } + } + } catch (InterruptedException stopped) { + Thread.currentThread().interrupt(); + } catch (RuntimeException unwatchable) { + // A look that cannot be made is not an end; the wait keeps its own deadline. + } + } + + @Override + public void close() { + thread.interrupt(); + } +} 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 082fc2dfe..6ced58c0a 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 @@ -5,6 +5,7 @@ import io.github.libtmux.Server; import io.github.libtmux.WakeReason; import io.github.libtmux.exception.DispatchException; +import io.github.libtmux.exception.LibTmuxException; import io.github.libtmux.transport.DispatchOutcome; import java.time.Duration; import java.util.List; @@ -49,7 +50,8 @@ private RunningCommands() {} /** * @param paneId the pane it ran in - * @param outcome why the wait ended, which is never simply "successfully" + * @param outcome why the wait ended, which is never simply "successfully": SIGNALLED, TIMED_OUT, + * PANE_GONE or SERVER_GONE * @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 @@ -110,21 +112,45 @@ static Ran run(Call call) { long started = System.nanoTime(); WakeReason wake; + PaneWatch watch = PaneWatch.over(freshPane, before, Trim.lineBudget(call), () -> { + try { + server.channel(run.channel()).signal(); + } catch (RuntimeException unsignalled) { + // The wait still ends at its own deadline. + } + }); try { wake = server.channel(run.channel()).await(timeout); } catch (InterruptedException cancelled) { throw Waits.cancelled(cancelled); + } finally { + watch.close(); } double seconds = (System.nanoTime() - started) / 1_000_000_000.0; - Screen.Fresh fresh = - wake == WakeReason.SERVER_GONE ? null : Screen.since(freshPane, before, Trim.lineBudget(call)); + Screen.Fresh fresh = null; + Ends ended = watch.ended(); + if (wake != WakeReason.SERVER_GONE && ended == null) { + try { + fresh = Screen.since(freshPane, before, Trim.lineBudget(call)); + } catch (LibTmuxException unread) { + // The pane or the server went away between the signal and this read. + ended = Ends.of(freshPane, unread); + } + } + if (fresh == null) { + // Nothing can be read from what is gone; the last look is the output so far. + fresh = watch.last(); + } PaneCommand.Framed framed = fresh == null ? new PaneCommand.Framed(List.of(), false, OptionalInt.empty()) : run.frame(fresh.lines()); - Integer status = wake == WakeReason.SIGNALLED && framed.status().isPresent() + Integer status = wake == WakeReason.SIGNALLED + && ended == null + && framed.status().isPresent() ? framed.status().getAsInt() : null; + String outcome = ended == Ends.PANE_GONE && wake != WakeReason.SERVER_GONE ? "PANE_GONE" : wake.name(); Trim.Trimmed trimmed = Trim.tail(framed.lines(), Trim.lineBudget(call)); if (status == null) { retained = true; @@ -133,7 +159,7 @@ static Ran run(Call call) { return new Ran( freshPane.id().value(), - wake.name(), + outcome, status, trimmed.lines(), trimmed.truncated(), @@ -141,7 +167,7 @@ static Ran run(Call call) { framed.exact(), Math.round(seconds * 100) / 100.0, Waits.asSeconds(timeout), - note(wake, framed)); + note(outcome, framed)); } catch (RuntimeException failure) { if (possiblyDispatched.get() && !retained) { retained = true; @@ -155,16 +181,19 @@ static Ran run(Call call) { } } - private static @Nullable String note(WakeReason wake, PaneCommand.Framed framed) { - return switch (wake) { - case TIMED_OUT -> + private static @Nullable String note(String outcome, PaneCommand.Framed framed) { + return switch (outcome) { + case "TIMED_OUT" -> "The command is still running; the output above is what it had printed by the " + "deadline. Call wait_for_text or capture_since on this pane to keep watching, " + "or send_keys with 'C-c' to stop it."; - case SERVER_GONE -> + case "PANE_GONE" -> + "The pane was killed or exited while the command was running, so it did not finish. The " + + "output above is what it had printed by the last look at the pane."; + case "SERVER_GONE" -> "The tmux server ended while the command was running. Nothing this call was " + "waiting on can be relied on; check that this process selected the intended socket."; - case SIGNALLED -> + default -> framed.exact() ? null : "The output could not be separated from the shell's echo exactly, so it may include the " 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 a8744a896..2a578feb3 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 @@ -6,6 +6,7 @@ import io.github.libtmux.batch.OperationResult; import io.github.libtmux.exception.CommandRejectedException; import io.github.libtmux.exception.MalformedResponseException; +import io.github.libtmux.transport.CommandResult; import java.util.ArrayList; import java.util.List; import org.jspecify.annotations.Nullable; @@ -109,6 +110,29 @@ static Fresh since(Pane pane, @Nullable Cursor from, int budget) { } static Fresh since(Pane pane, @Nullable Cursor from, int budget, @Nullable TypedText typed) { + return since(pane, from, budget, typed, Screen::look); + } + + /** + * What arrived since {@code from}, read so that no {@code after-display-message} hook fires. + * + *

{@link #since} reads through a batch, and a batch closes each operation with a + * {@code display-message}, which runs the user's hook for it. A caller that looks over and over + * while it waits would run that hook on every look. This reads the pane's position with + * {@code list-panes} and its content with {@code capture-pane}, as two invocations, so a pane + * that scrolls between them can leave the position a row off; the cost is a line withheld or + * delivered a look late. A cursor that moves up between the two reads can deliver a partial row + * once. A caller that needs an exact answer reads with {@link #since}. + */ + static Fresh sinceWithoutHooks(Pane pane, Cursor from, int budget) { + return since(pane, from, budget, null, Screen::quietLook); + } + + private interface Reader { + Look look(Pane pane, int lookback); + } + + private static Fresh since(Pane pane, @Nullable Cursor from, int budget, @Nullable TypedText typed, Reader reader) { if (from == null) { return from(pane); } @@ -117,12 +141,12 @@ static Fresh since(Pane pane, @Nullable Cursor from, int budget, @Nullable Typed throw new IllegalArgumentException( "that cursor belongs to pane " + from.paneId() + ", not " + paneId + "; each pane has its own"); } - Look look = look(pane, budget + SLACK_LINES); + Look look = reader.look(pane, budget + SLACK_LINES); Fresh answer = resolve(from, look); if (answer != null) { return discount(answer, look, typed); } - Look recovery = look(pane, CURSOR_RECOVERY_LINES); + Look recovery = reader.look(pane, CURSOR_RECOVERY_LINES); Fresh resumed = resolve(from, recovery); if (resumed != null) { return discount(resumed, recovery, typed); @@ -221,13 +245,40 @@ private static Look look(Pane pane, int lookback) { } OperationResult capture = read.operations().get(0); OperationResult position = read.operations().get(1); - long[] numbers = numbers(position.stdout()); + return place(capture.stdout(), numbers(position.stdout()), lookback); + } + + /** {@link #look} without a batch: the position first, so a scroll in between withholds rather than invents. */ + private static Look quietLook(Pane pane, int lookback) { + String id = pane.id().value(); + boolean everything = lookback >= Integer.MAX_VALUE; + String start = everything ? "-" : lookback <= 0 ? "0" : String.valueOf(-lookback); + CommandResult position = pane.server() + .cmd( + "list-panes", + "-a", + "-f", + "#{==:#{pane_id}," + id + "}", + "-F", + "#{pid} #{history_size} #{cursor_y}"); + if (!position.succeeded() || position.stdout().isEmpty()) { + throw new CommandRejectedException("pane " + id + " is not listed"); + } + CommandResult capture = pane.server().cmd("capture-pane", "-p", "-t", id, "-S", start); + if (!capture.succeeded()) { + throw new CommandRejectedException("could not capture pane " + id); + } + return place(capture.stdout(), numbers(position.stdout()), lookback); + } + + private static Look place(List lines, long[] numbers, int lookback) { + boolean everything = lookback >= Integer.MAX_VALUE; 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(capture.stdout(), history + cursorY - first, first == 0, serverPid); + return new Look(lines, history + cursorY - first, first == 0, serverPid); } private static long[] numbers(List stdout) { 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 809b95436..949889772 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,6 +2,7 @@ import io.github.libtmux.Pane; import io.github.libtmux.TypedText; +import io.github.libtmux.exception.LibTmuxException; import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -29,15 +30,19 @@ */ final class WaitingForText { + /** The most rows a match is looked for across before the whole region is joined instead. */ + private static final int WRAPPED_ROWS = 16; + private WaitingForText() {} /** - * @param outcome MATCHED, PRESENT_AT_ENTRY, STOPPED, TIMED_OUT or SERVER_GONE + * @param outcome MATCHED, PRESENT_AT_ENTRY, STOPPED, TIMED_OUT, PANE_GONE or SERVER_GONE * @param matched the pattern that ended the wait, absent when none did * @param matchedLine the line it was found on * @param output the lines the wait saw - new ones it watched for, or, for PRESENT_AT_ENTRY, the * ones already on screen when it started - newest last - * @param cursor where to resume watching without re-reading these lines + * @param cursor where to resume watching without re-reading these lines, empty when the pane or + * server went away before any look */ record Waited( String paneId, @@ -69,23 +74,7 @@ static Waited waitFor(Call call) { long started = System.nanoTime(); Optional cursorArgument = call.maybe("cursor"); - Cursor cursor; - if (cursorArgument.isPresent()) { - cursor = Cursor.decode(cursorArgument.get()); - } else { - // With no cursor to resume from, "now" is not "empty" - text the pane already - // printed is on screen whether it arrived a second ago or an hour ago, and a wait that - // only looks forward from this instant would never see it, timing out with empty output - // even while capture_pane shows the very thing it was asked for. - Screen.Fresh entry = Screen.completeOnly(pane, typed); - cursor = entry.cursor(); - List entryLines = entry.searchable(); - Waited early = - presentAtEntry(pane, timeout, wanted, stops, budget, entryLines, entry.lines(), cursor, started); - if (early != null) { - return early; - } - } + @Nullable Cursor cursor = cursorArgument.map(Cursor::decode).orElse(null); Trim.Trimmed retained = new Trim.Trimmed(List.of(), 0); long deadline = started + timeout.toNanos(); @@ -93,46 +82,66 @@ static Waited waitFor(Call call) { TextPatterns.Matcher hit = null; String hitLine = null; - while (true) { - Screen.Fresh fresh = Screen.since(pane, cursor, budget, typed); - cursor = fresh.cursor(); - List freshLines = fresh.searchable(); - retained = Trim.append(retained, freshLines, 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. - Found stopped = find(stops, freshLines, fresh.lines()); - if (stopped != null) { - outcome = "STOPPED"; - hit = stopped.matcher(); - hitLine = stopped.line(); - break; - } - Found found = find(wanted, freshLines, fresh.lines()); - if (found != null) { - outcome = "MATCHED"; - hit = found.matcher(); - hitLine = found.line(); - break; - } - if (wanted.isEmpty() && !freshLines.isEmpty()) { - outcome = "MATCHED"; - hitLine = fresh.lines().get(freshLines.size() - 1); - break; - } - if (System.nanoTime() >= deadline) { - break; + try { + if (cursor == null) { + // With no cursor to resume from, "now" is not "empty" - text the pane already + // printed is on screen whether it arrived a second ago or an hour ago, and a wait + // that only looks forward from this instant would never see it, timing out with + // empty output even while capture_pane shows the very thing it was asked for. + Screen.Fresh entry = Screen.completeOnly(pane, typed); + cursor = entry.cursor(); + List entryLines = entry.searchable(); + Waited early = presentAtEntry( + pane, timeout, wanted, stops, budget, entryLines, entry.lines(), cursor, started); + if (early != null) { + return early; + } } - long elapsed = System.nanoTime() - started; - call.progress() - .report( - Duration.ofNanos(elapsed), - timeout, - "watching " + pane.id().value() + " for " - + (wanted.isEmpty() ? "any output" : wanted.size() + " pattern(s)")); - if (!sleep()) { - break; + + while (true) { + Screen.Fresh fresh = Screen.since(pane, cursor, budget, typed); + cursor = fresh.cursor(); + List freshLines = fresh.searchable(); + retained = Trim.append(retained, freshLines, 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. + Found stopped = find(stops, freshLines, fresh.lines()); + if (stopped != null) { + outcome = "STOPPED"; + hit = stopped.matcher(); + hitLine = stopped.line(); + break; + } + Found found = find(wanted, freshLines, fresh.lines()); + if (found != null) { + outcome = "MATCHED"; + hit = found.matcher(); + hitLine = found.line(); + break; + } + if (wanted.isEmpty() && !freshLines.isEmpty()) { + outcome = "MATCHED"; + hitLine = fresh.lines().get(freshLines.size() - 1); + break; + } + if (System.nanoTime() >= deadline) { + break; + } + long elapsed = System.nanoTime() - started; + call.progress() + .report( + Duration.ofNanos(elapsed), + timeout, + "watching " + pane.id().value() + " for " + + (wanted.isEmpty() ? "any output" : wanted.size() + " pattern(s)")); + if (!sleep()) { + break; + } } + } catch (LibTmuxException unread) { + // A pane or a server that went away mid-wait is the answer, with what was read so far. + outcome = Ends.of(pane, unread).name(); } if (!pane.server().isAlive()) { @@ -147,7 +156,7 @@ static Waited waitFor(Call call) { retained.lines(), retained.truncated(), retained.dropped(), - cursor.encode(), + cursor == null ? "" : cursor.encode(), Math.round(seconds * 100) / 100.0, Waits.asSeconds(timeout), note(outcome, wanted, stops)); @@ -166,6 +175,10 @@ static Waited waitFor(Call call) { if ("STOPPED".equals(outcome)) { return "A stop pattern matched, so the wait ended early. This is a failure, not a success."; } + if ("PANE_GONE".equals(outcome)) { + return "The pane was killed or exited while waiting; output holds what it printed before then, " + + "and nothing more will appear. A cursor from before cannot be used on a new pane."; + } if ("SERVER_GONE".equals(outcome)) { return "The tmux server ended while waiting; nothing this call watched can be relied on."; } @@ -255,11 +268,21 @@ private record Found(TextPatterns.Matcher matcher, String line) {} if (lines.size() < 2) { return null; } - StringBuilder joined = new StringBuilder(); - for (String line : lines) { - joined.append(line); + // The rows that carry the match, and no others: the narrowest run of rows that matches when + // joined. Joining the whole region would report a second prompt and the typed command as part + // of one "line". A run wider than the cap is found below, whole. + int widest = Math.min(lines.size(), WRAPPED_ROWS); + for (int width = 2; width <= widest; width++) { + for (int start = 0; start + width <= lines.size(); start++) { + String text = String.join("", lines.subList(start, start + width)); + for (TextPatterns.Matcher matcher : matchers) { + if (matcher.matches(text)) { + return new Found(matcher, String.join("", originals.subList(start, start + width))); + } + } + } } - String text = joined.toString(); + String text = String.join("", lines); for (TextPatterns.Matcher matcher : matchers) { if (matcher.matches(text)) { return new Found(matcher, String.join("", originals)); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/DyingPane.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/DyingPane.java new file mode 100644 index 000000000..4399d6ffa --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/DyingPane.java @@ -0,0 +1,50 @@ +package io.github.libtmux.mcp; + +import io.github.libtmux.Pane; +import io.github.libtmux.Server; +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** A pane that prints once and then idles, on a server that has another session to stay up in. */ +final class DyingPane implements AutoCloseable { + + static final String PRINTED = "printed-before-the-end"; + + final Pane pane; + private final StoppedClient client; + + private DyingPane(Pane pane, StoppedClient client) { + this.pane = pane; + this.client = client; + } + + /** A pane that prints {@link #PRINTED} a moment after it starts, beside a session that outlives it. */ + static DyingPane start(Server server, boolean withStoppedClient) throws IOException, InterruptedException { + server.newSession(session -> session.named("outlives")); + Pane pane = server.newSession(session -> + session.named("victim").running("sh", "-c", "sleep 1; echo " + PRINTED + "; sleep 300")) + .windows() + .get(0) + .panes() + .get(0); + return new DyingPane(pane, StoppedClient.attach(server, "outlives", withStoppedClient)); + } + + /** Waits for the pane to have printed, then for the wait to have had time to read it. */ + void awaitPrinted() throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20); + while (System.nanoTime() < deadline) { + if (pane.capture().stream().anyMatch(line -> line.contains(PRINTED))) { + Thread.sleep(600); + return; + } + Thread.sleep(50); + } + throw new IllegalStateException("the pane never printed"); + } + + @Override + public void close() throws IOException { + client.close(); + } +} 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 937e533e6..4883ec6f9 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 @@ -24,7 +24,9 @@ import java.util.Optional; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -54,6 +56,137 @@ final class RunningCommandsTest { private static final Pattern NONCE = Pattern.compile("\\blt[0-9a-f]{32}\\b"); + /** + * A pane killed under a running command ends the call at once with a failed answer and what the + * command had printed, not at the timeout, with or without a stopped second client on the socket. + */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void aPaneKilledUnderACommandEndsTheCallPromptly(boolean withStoppedClient, Server server) throws Exception { + server.newSession(session -> session.named("outlives")); + try (StoppedClient _ = StoppedClient.attach(server, "outlives", withStoppedClient)) { + Pane pane = server.newSession(session -> session.named("shell").running("env", "PS1=$ ", "ENV=", "/bin/sh")) + .windows() + .get(0) + .panes() + .get(0); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future running = executor.submit(() -> RunningCommands.run(TestCalls.on( + server, + "pane_id", + pane.id().value(), + "command", + "echo " + DyingPane.PRINTED + "; sleep 300", + "timeout", + 60))); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20); + while (pane.capture().stream().noneMatch(line -> line.equals(DyingPane.PRINTED)) + && System.nanoTime() < deadline) { + Thread.sleep(50); + } + Thread.sleep(300); + pane.kill(); + + RunningCommands.Ran ran = running.get(15, TimeUnit.SECONDS); + + assertEquals("PANE_GONE", ran.outcome()); + assertNull(ran.exitStatus(), "a command whose pane died did not finish"); + assertTrue(ran.output().contains(DyingPane.PRINTED), "output so far: " + ran.output()); + assertTrue(ran.seconds() < 10, "it must not wait out its timeout: " + ran.seconds()); + } finally { + executor.shutdownNow(); + } + } + } + + /** + * The look at a running command's pane runs on an interval and must not run the user's + * {@code after-display-message} hook each time. The control shows the hook does fire for a + * {@code display-message}, so a count that stays flat means the looks are silent, not that the + * hook is broken. + */ + @Test + void theLookAtARunningCommandFiresNoDisplayMessageHook(Server server, @TempDir Path temporary) throws Exception { + Path fires = temporary.resolve("fires"); + Files.writeString(fires, ""); + assertTrue(server.cmd("set-hook", "-g", "after-display-message", "run-shell -b 'echo x >> " + fires + "'") + .succeeded()); + Pane pane = server.newSession(session -> session.named("shell").running("env", "PS1=$ ", "ENV=", "/bin/sh")) + .windows() + .get(0) + .panes() + .get(0); + Thread.sleep(300); + int before = hookFires(fires, 0); + server.cmd("display-message", "-p", "control"); + assertEquals(before + 1, hookFires(fires, before + 1), "a display-message fires the hook"); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future running = executor.submit(() -> RunningCommands.run( + TestCalls.on(server, "pane_id", pane.id().value(), "command", "sleep 3", "timeout", 60))); + Thread.sleep(1000); + int settled = hookFires(fires, 0); + Thread.sleep(1500); + assertEquals( + settled, + hookFires(fires, 0), + "the watch looked at the pane several times and no look may fire the hook"); + assertEquals("SIGNALLED", running.get(30, TimeUnit.SECONDS).outcome()); + } finally { + executor.shutdownNow(); + server.cmd("set-hook", "-gu", "after-display-message"); + } + } + + /** Lines in the hook's file, after giving the backgrounded hook a moment to land. */ + private static int hookFires(Path fires, int atLeast) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + int seen; + do { + seen = Files.readAllLines(fires).size(); + if (seen >= atLeast) break; + Thread.sleep(50); + } while (System.nanoTime() < deadline); + return seen; + } + + /** {@code kill-server} under a running command is {@code SERVER_GONE}, with the output so far. */ + @Test + void aServerKilledUnderACommandKeepsTheOutputSoFar(Server server) throws Exception { + Pane pane = server.newSession(session -> session.named("shell").running("env", "PS1=$ ", "ENV=", "/bin/sh")) + .windows() + .get(0) + .panes() + .get(0); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future running = executor.submit(() -> RunningCommands.run(TestCalls.on( + server, + "pane_id", + pane.id().value(), + "command", + "echo " + DyingPane.PRINTED + "; sleep 300", + "timeout", + 60))); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20); + while (pane.capture().stream().noneMatch(line -> line.equals(DyingPane.PRINTED)) + && System.nanoTime() < deadline) { + Thread.sleep(50); + } + Thread.sleep(300); + server.cmd("kill-server"); + + RunningCommands.Ran ran = running.get(15, TimeUnit.SECONDS); + + assertEquals("SERVER_GONE", ran.outcome()); + assertTrue(ran.output().contains(DyingPane.PRINTED), "output so far: " + ran.output()); + } finally { + executor.shutdownNow(); + } + } + /** * 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. diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/StoppedClient.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/StoppedClient.java new file mode 100644 index 000000000..ec143192d --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/StoppedClient.java @@ -0,0 +1,68 @@ +package io.github.libtmux.mcp; + +import io.github.libtmux.Server; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.jspecify.annotations.Nullable; + +/** + * A second control client, attached and then stopped with SIGSTOP, or nothing at all. + * + *

tmux leaves the daemon running after {@code kill-server} until every client has left, so a wait + * that decided the server was gone by counting clients or by a timer would answer differently with + * one stopped on the socket. Each case runs both ways. + */ +final class StoppedClient implements AutoCloseable { + + private final @Nullable Process process; + + private StoppedClient(@Nullable Process process) { + this.process = process; + } + + /** Attaches and stops a client to {@code session} when {@code wanted}; otherwise does nothing. */ + static StoppedClient attach(Server server, String session, boolean wanted) + throws IOException, InterruptedException { + if (!wanted) { + return new StoppedClient(null); + } + List argv = new ArrayList<>(server.config().endpointCommand()); + argv.addAll(List.of("-C", "attach-session", "-t", session)); + ProcessBuilder builder = new ProcessBuilder(argv); + builder.environment().remove("TMUX"); + Process client = builder.redirectErrorStream(true).start(); + StoppedClient stopped = new StoppedClient(client); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (server.clients().isEmpty() && System.nanoTime() < deadline) { + Thread.sleep(20); + } + if (server.clients().isEmpty()) { + stopped.close(); + throw new IOException("the control client never attached"); + } + signal("STOP", client.pid()); + return stopped; + } + + private static void signal(String name, long pid) throws IOException, InterruptedException { + new ProcessBuilder("kill", "-" + name, Long.toString(pid)).start().waitFor(); + } + + @Override + public void close() throws IOException { + Process client = process; + if (client == null) { + return; + } + try { + signal("CONT", client.pid()); + client.getOutputStream().close(); + if (!client.waitFor(5, TimeUnit.SECONDS)) client.destroyForcibly(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + client.destroyForcibly(); + } + } +} 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 2f370ef1c..12fab0ee5 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 @@ -9,8 +9,14 @@ import io.github.libtmux.Server; import io.github.libtmux.junit5.TmuxExtension; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; /** * Waiting for output nobody here started. @@ -45,6 +51,10 @@ void outputTheTerminalWrappedForDisplayIsStillMatched(Server server) { TestCalls.on(server, "pane_id", pane, "patterns", List.of(marker), "timeout", 20)); assertEquals("MATCHED", waited.outcome(), "output the wait saw: " + waited.output()); + String line = String.valueOf(waited.matchedLine()); + assertTrue(line.endsWith(marker), "the rows that carry the match: " + line); + assertTrue(!line.contains("echo ") && !line.contains("sleep"), "not the typed command: " + line); + assertTrue(line.length() < marker.length() + 80, "not the region around it: " + line); } @Test @@ -283,6 +293,69 @@ void theTimeoutIsClampedToTheCeiling(Server server) { assertTrue(waited.effectiveTimeout() <= Waits.CEILING.toSeconds()); } + /** + * A pane killed mid-wait is an answer, not a transport error: the wait ends as {@code PANE_GONE} + * with what it had read, with or without a stopped second client on the socket. + */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void aPaneKilledMidWaitEndsThroughAnAnswerWithTheOutputSoFar(boolean withStoppedClient, Server server) + throws Exception { + try (DyingPane dying = DyingPane.start(server, withStoppedClient)) { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future waiting = executor.submit(() -> WaitingForText.waitFor(TestCalls.on( + server, + "pane_id", + dying.pane.id().value(), + "patterns", + List.of("never-appears"), + "timeout", + 60))); + dying.awaitPrinted(); + dying.pane.kill(); + + WaitingForText.Waited waited = waiting.get(15, TimeUnit.SECONDS); + + assertEquals("PANE_GONE", waited.outcome()); + assertTrue(waited.output().contains(DyingPane.PRINTED), "output so far: " + waited.output()); + assertTrue(waited.seconds() < 10, "no timer decides this: " + waited.seconds()); + } finally { + executor.shutdownNow(); + } + } + } + + /** As above for {@code kill-server}: the documented {@code SERVER_GONE}, with the output so far. */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void aServerKilledMidWaitEndsAsServerGoneWithTheOutputSoFar(boolean withStoppedClient, Server server) + throws Exception { + try (DyingPane dying = DyingPane.start(server, withStoppedClient)) { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future waiting = executor.submit(() -> WaitingForText.waitFor(TestCalls.on( + server, + "pane_id", + dying.pane.id().value(), + "patterns", + List.of("never-appears"), + "timeout", + 60))); + dying.awaitPrinted(); + server.cmd("kill-server"); + + WaitingForText.Waited waited = waiting.get(15, TimeUnit.SECONDS); + + assertEquals("SERVER_GONE", waited.outcome()); + assertTrue(waited.output().contains(DyingPane.PRINTED), "output so far: " + waited.output()); + assertTrue(waited.seconds() < 10, "no timer decides this: " + waited.seconds()); + } finally { + executor.shutdownNow(); + } + } + } + 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()) From 2e1a28dc2dd2e927f703b32bff460080e1771378 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Oct 2026 05:10:45 -0500 Subject: [PATCH 16/18] Features(test): Send the clipboard query and the leave apart why: The survival test sent the query and the detach in one tmux invocation, which leaves the terminal no time to receive the query, so an ungated daemon survived most runs and the javadoc claimed more than that. Sent as two commands, as the reviewer's probe does, the leave ends an ungated 3.5 or 3.6 daemon on most runs. what: - Send refresh-client -l as its own command, then detach the client, then read the daemon after a second. - Record whether the query was refused and assert it last, so an ungated run fails by naming the ended daemon or the missing refusal. - Say in the javadoc that the refusal is the deterministic assertion and the daemon check only backs it. - Measured with the gate removed, 24 runs per release in two JVMs: the daemon ended in 18 on 3.5 and in 14 on 3.6, and every run failed on one of the two assertions. Gated, it passes on 3.5, 3.6, 3.6a and 3.7. --- .../it/OptionalFeatureIntegrationTest.java | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java index f192eb545..f25acc740 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java @@ -105,36 +105,34 @@ void clipboardQueryIsRefusedBelowItsReleaseAndTheDaemonStaysUp(Server server) th } /** - * The failure on 3.4 to 3.6 is a race between the query and its client leaving, so the query and - * the client's detach travel in one tmux invocation, which leaves the terminal no time to answer. - * Below 3.6a the batch is refused whole; from 3.6a it runs, and either way the daemon is read - * afterwards. Without the refusal the daemon of a 3.5 or 3.6 server does not survive this. + * The failure on 3.4 to 3.6 is a race between the query and its client leaving: the query reaches + * the client's terminal, the client leaves before the terminal answers, and the daemon ends. The + * query is sent as its own command and the client then detaches, the order that ends an ungated + * daemon on those releases. + * + *

The refusal is the deterministic assertion. The leave step ends an ungated daemon on most + * runs but not on every one, so the daemon check backs the refusal and cannot replace it: a + * pass there alone says nothing about the gate. */ @Test void aClientLeavingAfterTheQueryCannotEndTheDaemon(Server server) throws Exception { try (NativeClient client = NativeClient.attach(server.sessions().getFirst())) { String name = server.clients().getFirst().name(); - if (server.version().atLeast(CLIPBOARD)) { - server.batch() - .add("refresh-client", "-t", name, "-l") - .add("detach-client", "-t", name) - .run(); - } else { - assertThrows( - UnsupportedFeatureException.class, - () -> server.batch() - .add("refresh-client", "-t", name, "-l") - .add("detach-client", "-t", name) - .run()); - assertTrue(client.isAlive(), "a refused batch must not have detached the client"); - server.cmd("detach-client", "-t", name); + boolean refused = false; + try { + server.cmd("refresh-client", "-t", name, "-l"); + } catch (UnsupportedFeatureException refusal) { + refused = true; } + assertTrue(client.isAlive(), "a query must not have detached the client"); + server.cmd("detach-client", "-t", name); assertTrue(Await.until(() -> !client.isAlive())); + // The daemon ends a moment after the client is gone, not while it leaves. + Thread.sleep(1000); + assertTrue(server.isAlive(), "the daemon ended after the client left"); + assertTrue(server.cmd("list-sessions").succeeded()); + assertEquals(!server.version().atLeast(CLIPBOARD), refused, "the clipboard query's refusal"); } - // The server ends a moment after the client is gone, not while it leaves. - Thread.sleep(1000); - assertTrue(server.isAlive()); - assertTrue(server.cmd("list-sessions").succeeded()); } @Test From d4df80ef076db18c35647d0038c9f7fa6de4e2a9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Oct 2026 05:10:45 -0500 Subject: [PATCH 17/18] Matrix(feat): Run the clipboard floor release 3.6a in CI why: 3.6a is the release the refresh-client -l floor sits on, and no CI lane ran it, so the edge between refused and allowed rested on a local measurement. what: - Add 3.6a to the plugin's lanes and the workflow matrix, between 3.6 and 3.7, with the control-tagged tests excluded as on 3.3a to 3.6. - List it in the Scala compatibility page, which names every lane. --- .github/workflows/tmux-matrix.yml | 2 +- .../src/main/kotlin/libtmux.tmux-matrix.gradle.kts | 4 ++-- docs/guide/scala/compatibility.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tmux-matrix.yml b/.github/workflows/tmux-matrix.yml index df7f0459a..eb973fca8 100644 --- a/.github/workflows/tmux-matrix.yml +++ b/.github/workflows/tmux-matrix.yml @@ -38,7 +38,7 @@ jobs: # (its `lanes` list), which is the source of truth. The agree step below fails if they drift # apart. This is also what DocumentationFactsTest reads the README's claimed range from, so a # preview lane belongs in `include` below and never here. - tmux: ['3.3a', '3.4', '3.5', '3.6', '3.7', '3.7a', '3.7b', '3.7c'] + tmux: ['3.3a', '3.4', '3.5', '3.6', '3.6a', '3.7', '3.7a', '3.7b', '3.7c'] # Ahead of the supported range, matching the plugin's `previewLanes` list: 3.8-rc2 is a # candidate for a release not yet cut, and master is upstream's development branch, not a # version. Both may fail and neither moves the README's claimed range. diff --git a/build-logic/conventions/src/main/kotlin/libtmux.tmux-matrix.gradle.kts b/build-logic/conventions/src/main/kotlin/libtmux.tmux-matrix.gradle.kts index 06077ede2..75044c805 100644 --- a/build-logic/conventions/src/main/kotlin/libtmux.tmux-matrix.gradle.kts +++ b/build-logic/conventions/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.3a", "3.4", "3.5", "3.6", "3.7", "3.7a", "3.7b", "3.7c") +val lanes = listOf("3.3a", "3.4", "3.5", "3.6", "3.6a", "3.7", "3.7a", "3.7b", "3.7c") // Ahead of the supported range: previews of what tmux ships next, run so a break shows up before the // release that makes it real rather than after. Allowed to fail, and left out of testTmuxMatrix and @@ -38,7 +38,7 @@ fun registerLane(lane: String, rejection: Boolean = false) = useJUnitPlatform { excludeTags("fixture", "carrier", "benchmark", "locale") // Ordinary 3.3a+ operations still run; managed control has its own 3.7 floor. - if (lane in listOf("3.3a", "3.4", "3.5", "3.6")) { + if (lane in listOf("3.3a", "3.4", "3.5", "3.6", "3.6a")) { excludeTags("control") } } diff --git a/docs/guide/scala/compatibility.md b/docs/guide/scala/compatibility.md index 2ec53a321..242a91c71 100644 --- a/docs/guide/scala/compatibility.md +++ b/docs/guide/scala/compatibility.md @@ -10,7 +10,7 @@ the facades' unit suites, their real-tmux suites, the documented Scala examples and the runnable programs on JDK 25 and 27 on Linux. Every lane of the [tmux matrix][tmux-matrix] runs the real-tmux suites against -its own tmux: `3.3a`, `3.4`, `3.5`, `3.6`, `3.7`, `3.7a`, `3.7b` and `3.7c`. +its own tmux: `3.3a`, `3.4`, `3.5`, `3.6`, `3.6a`, `3.7`, `3.7a`, `3.7b` and `3.7c`. The Scala fixture takes the lane's tmux the same way the Java one does. The facades inherit Java's version check and reject older daemons. From 2c45a2efc911d90a0950768b21e412703eac13f0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 19 Oct 2026 19:06:40 -0500 Subject: [PATCH 18/18] Changelog(docs): Record the optional tmux requirements and capabilities why: Callers need to know that managed control, named buffer edits, layouts, splits and breaks have requirements above the 3.3a floor, that exact 3.7 refuses delimiters in names, and that the library now publishes what each release supports for the docs capability pages. what: - Add the control floor, the 3.4 buffer boundary, the layout, split and break refusals and the Python policy marker to Unreleased, with a link to the migration notes, citing #72. - Note that names with a dot or colon are refused before dispatch on exactly 3.7. - Add the capability registry and capabilities.json manifest. - Record the clipboard query floor of 3.6a, that no released Python libtmux meets the optional-policy requirement yet, PANE_GONE and Pane.watchText, the wait_for_text and run_shell_command fix with its interval check, and the nested-command javadoc. --- CHANGELOG.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56243ad11..5ba0093fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,61 @@ production. ## Unreleased +### Added + +- **Capabilities are recorded in one registry and a `capabilities.json` + manifest.** The registry names each optional tmux feature and release defect + the library gates, with the release it needs and how it refuses or repairs. + The gates read it, and the manifest at the repository root is written from it + for the capability pages on libtmux.org. (#72) +- **`TextOutcome.PANE_GONE`, `TextWait` and `Pane.watchText`.** `watchText` + waits like `Pane.awaitText` and also returns the pane's output at its last + read. (#72) + +### Changed + +- **Managed control now requires tmux 3.7+ and a pinned socket.** Older + daemons are refused before control starts; ordinary operations keep the 3.3a + minimum. See [migration notes](MIGRATION.md#optional-tmux-requirements). (#72) +- **Named buffer deletion, rename and managed paste require tmux 3.4+.** On + 3.3a a missing name can select an unrelated buffer. (#72) +- **Invalid literal layouts are refused on tmux 3.3a.** The refusal is an + `UnsupportedFeatureException` before any command of the batch runs. (#72) +- **Literal `split-window -p` is refused on exact tmux 3.4.** Typed percentage + splits keep working. (#72) +- **`refresh-client -l` is refused below tmux 3.6a.** On 3.3a it ends the server + when a client attached from an `xterm` or `tmux-256color` terminal receives + the query, and on 3.4 to 3.6 it ends the server when the client leaves before + its terminal answers. (#72) +- **Raw and unnamed `break-pane` is refused on exact tmux 3.7.** Named breaks + retain the requested name, and single-pane breaks also work without native + `-P` output. (#72) +- **Exact tmux 3.7 refuses `.` and `:` in session and window names before + dispatch.** A batch or workspace no longer runs the commands ahead of the + one tmux would reject. (#72) +- **Python extensions require optional-policy v2.** `tmux-workspace shell` and + extension loading need a Python libtmux with + `TMUX_OPTIONAL_FEATURE_POLICY_VERSION >= 2`. No released libtmux has it yet; + it comes with tmux-python/libtmux#780, so until then install libtmux from + that source or leave `shell` and extensions alone. (#72) +- **`Pane.awaitText` answers `TextOutcome.PANE_GONE` for a pane killed + mid-wait** instead of throwing `CommandRejectedException`. (#72) +- **`Server.cmd`, `batch`, `chain` and `ControlClient.send` say that nested + command strings are not checked.** An `if-shell` body, a hook or a + `source-file` keeps tmux's own behaviour, so do not nest a command the + library refuses. (#72) + +### Fixed + +- **`wait_for_text` and `run_shell_command` answer for a gone pane or server.** + A pane killed mid-wait ends the call as `PANE_GONE` and a killed server as + `SERVER_GONE`, both with the output seen so far, where the tool used to + return an error or wait out the timeout. A bounded check of the pane runs + on an interval while `run_shell_command` blocks, so a killed pane ends it + within about a quarter second; the check reads through `list-panes` and + `capture-pane`, so it does not run an `after-display-message` hook. `matched_line` for a match split + across wrapped rows is now only the rows that carry it. (#72) + ### Development - **CI no longer runs macOS.** macOS stays supported on a best-effort basis, and