From 645eaea8094d663adcaa3f30b8a0157d7e9cf1c7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 04:27:52 -0500 Subject: [PATCH 01/13] 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 1db026f1410e5e619c62d89844cb9f89e956bbab Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 04:27:52 -0500 Subject: [PATCH 02/13] 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. 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 | 4 +- .../workspace/cli/PythonExtensions.java | 4 ++ .../libtmux/workspace/cli/ExecutionTest.java | 53 ++++++++++++++++++- 5 files changed, 64 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..ad7734860 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,9 @@ 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 the coordinated Python libtmux installed"); 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..eb2c7faf6 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; install the coordinated Python libtmux in TMUX_WORKSPACE_PYTHON') """.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 9229289134ecc145ab8004aba8f359b8fef38361 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 04:28:08 -0500 Subject: [PATCH 03/13] 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. 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 | 64 +++--- .../main/java/io/github/libtmux/Server.java | 37 +++- .../github/libtmux/internal/TmuxFeatures.java | 131 +++++++++++++ .../libtmux/internal/support/Versions.java | 32 ++- 8 files changed, 435 insertions(+), 71 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..5ed49fdb2 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -696,22 +696,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 +810,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 +953,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 +972,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 +1003,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..c2ed266d1 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; @@ -491,7 +492,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); } @@ -866,6 +867,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 +1326,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 +1344,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 +1369,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 +1386,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/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 6e45cb7d4fe0beffbcdb18f13d25b650193e87e2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 04:28:24 -0500 Subject: [PATCH 04/13] 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 | 59 ++++++ .../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, 1274 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..f7d0dddef --- /dev/null +++ b/integration-tests/src/test/java/io/github/libtmux/it/ControlFloorIntegrationTest.java @@ -0,0 +1,59 @@ +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)) { + assertThrows( + UnsupportedFeatureException.class, + () -> client.send( + "break-pane", "-n", "bad", "-s", pane.id().value())); + assertThrows( + UnsupportedFeatureException.class, + () -> server.cmd("breakp", "-n", "bad", "-s", pane.id().value())); + assertTrue(client.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 c2ed266d1..fa728ceef 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -930,6 +930,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 0213fafec..0f3ea2231 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()), @@ -349,6 +364,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); @@ -485,32 +502,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(); } } @@ -548,6 +569,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 b34bc7d0c75594bf36480aeac996f8a377dad602 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 04:28:37 -0500 Subject: [PATCH 05/13] 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. --- .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 | 6 +++-- 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/README.md | 1 + 22 files changed, 148 insertions(+), 43 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..384228b38 100644 --- a/docs/guide/workspace-cli.md +++ b/docs/guide/workspace-cli.md @@ -354,8 +354,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/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 5c40806f4896430e1692c0caf66335aefa8f88ac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 04:28:37 -0500 Subject: [PATCH 06/13] 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 opaque programs and aliases, and the pinSocket contract for custom carriers. - The Python bridge requires optional-policy marker 2. --- MIGRATION.md | 67 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 9567f72a6..23afa1352 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,57 @@ 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. + +### 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, +without interpreting user-authored `if-shell` bodies, `source-file` contents, +shell programs or configured aliases. Those opaque programs retain native +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 dc956c0d9e3e0d4f459fbf314daf3b6aaf111bb5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 05:00:06 -0500 Subject: [PATCH 07/13] 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 fa728ceef..89363dd0c 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -931,32 +931,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 0f3ea2231..adf42b0c6 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 c1024e7d16455acad77b1066abf001cc67f0f36b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 08:08:25 -0500 Subject: [PATCH 08/13] 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 dbdc21e02cf565d97fbbd7621633250f3ab8ad43 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 09:17:59 -0500 Subject: [PATCH 09/13] 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 f8faded438b4937ffb53ba7c379e9e0468c5241b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 09:18:11 -0500 Subject: [PATCH 10/13] 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 23afa1352..b35362da8 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -80,6 +80,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 29a65f8ba61ce381d6b7a81980e704598877d6fd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 11:24:36 -0500 Subject: [PATCH 11/13] 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 8acecafb20eb0323e78ce01c4e82c6095e13df58 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 19 Oct 2026 19:06:18 -0500 Subject: [PATCH 12/13] 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. - 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. --- capabilities.json | 227 ++++++++++ libtmux/build.gradle.kts | 8 +- .../java/io/github/libtmux/CaptureSpec.java | 3 +- .../main/java/io/github/libtmux/Layout.java | 4 +- .../main/java/io/github/libtmux/Layouts.java | 3 +- .../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 | 26 +- .../internal/support/Capabilities.java | 389 ++++++++++++++++++ .../internal/support/CapabilitiesTest.java | 210 ++++++++++ 12 files changed, 870 insertions(+), 44 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/internal/support/CapabilitiesTest.java diff --git a/capabilities.json b/capabilities.json new file mode 100644 index 000000000..ca7b0f06d --- /dev/null +++ b/capabilities.json @@ -0,0 +1,227 @@ +{ + "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": [ + "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." + } + ], + "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 ignores -n for a single pane, 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/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..a0694e714 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,7 +32,7 @@ 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, ""); 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..c9c48e663 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,7 @@ 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(); private Layouts() {} 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 5ed49fdb2..fe872daac 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; @@ -813,9 +803,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+"); } @@ -831,7 +828,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..6a041a06c 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')) { 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..d5d9099e5 --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/internal/support/Capabilities.java @@ -0,0 +1,389 @@ +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")))); + + /** + * Gates {@code select-layout}, but is left out of {@link #capabilities()}: the refusal is an + * {@link IllegalArgumentException}, which the generated API reference does not carry, and a + * manifest entry must name an error the documentation can link. + */ + 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, + "IllegalArgumentException", + List.of("Window.selectLayout", "Layouts.require", "Server.cmd", "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_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 ignores -n for a single pane, 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_MIRRORED_MAIN, + LAYOUT_JSON, + CAPTURE_TRIM_BLANK_CELLS); + + 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/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 6d73f68beac2d19ab7f58ecad620cd0926213176 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 19 Oct 2026 19:06:40 -0500 Subject: [PATCH 13/13] 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. --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56243ad11..d32f00ecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,28 @@ 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) + +### Changed + +- **Managed control now requires tmux 3.7+ and a pinned socket.** + Ordinary operations keep the 3.3a minimum. Named buffer deletion, rename + and managed paste require 3.4; invalid literal layouts are refused on 3.3a. + Literal percentage splits are refused on exact 3.4, and raw or unnamed + breaks on exact 3.7. Named breaks retain the requested name; single-pane + breaks also work without native `-P` output. Python extensions require + optional-policy v2. See + [migration notes](MIGRATION.md#optional-tmux-requirements). (#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) + ### Development - **CI no longer runs macOS.** macOS stays supported on a best-effort basis, and