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/.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/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 diff --git a/MIGRATION.md b/MIGRATION.md index 9567f72a6..b35362da8 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,58 @@ 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. | +| 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. | + +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 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/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/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/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/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 = 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/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..8d8b8ba39 --- /dev/null +++ b/integration-tests/src/test/java/io/github/libtmux/it/ControlAcquisitionIntegrationTest.java @@ -0,0 +1,226 @@ +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); + } + } + + /** 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/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/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/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/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/OptionalFeatureIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java new file mode 100644 index 000000000..8b0f04af0 --- /dev/null +++ b/integration-tests/src/test/java/io/github/libtmux/it/OptionalFeatureIntegrationTest.java @@ -0,0 +1,205 @@ +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())); + } + + @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/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/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/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..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 @@ -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,56 @@ 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.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) + .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 +326,19 @@ 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; - VersionProbe(String stderr) { - this.error = new ByteArrayInputStream((stderr + "\n").getBytes(StandardCharsets.UTF_8)); + 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)); } - private final InputStream output = InputStream.nullInputStream(); + static VersionProbe refusing(String stderr) { + return new VersionProbe(1, "", stderr); + } @Override public OutputStream getOutputStream() { @@ -305,12 +357,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/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-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..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 @@ -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,32 @@ 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 + 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 + return 0 + 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-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-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-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..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 @@ -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,22 @@ 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 + | 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 + | return 0 + | done + | return 1 + |} |answer() { | printf '%%begin 1 1 1\n' | for line in "$@"; do printf '%s\n' "$line"; done diff --git a/libtmux-scala-ox/README.md b/libtmux-scala-ox/README.md index 3ee4775c3..d6215142a 100644 --- a/libtmux-scala-ox/README.md +++ b/libtmux-scala-ox/README.md @@ -18,8 +18,9 @@ concurrency on virtual threads.** [`Flows.liveView`][flows] and [`Flows.subscription`][flows] turn pushed events into a cold `Flow`: a live view of a server, and a control client's -subscription. The facade's handles work unchanged in an Ox `supervised` -scope. +subscription. Acquiring either requires tmux 3.7+ and a carrier-owned pinned +socket; ordinary direct-style operations keep the core 3.3a minimum. The +facade's handles work unchanged in an Ox `supervised` scope. `io.github.libtmux:libtmux-scala-ox_3` — on Maven Central, released with the Java artifacts. @@ -49,7 +50,7 @@ It emits the current view of a server when the flow starts, then every newer one, so a change published between reading a view and collecting the flow is not missed. Here `config` selects an existing tmux server: - + ```scala import _root_.ox.{supervised, useCloseableInScope} import io.github.libtmux.scaladsl.{config => _, *} diff --git a/libtmux-workspace-cli/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/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()) 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; 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/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); 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/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/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/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/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 new file mode 100644 index 000000000..1eddac499 --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/Names.java @@ -0,0 +1,44 @@ +package io.github.libtmux; + +import io.github.libtmux.internal.support.Capabilities; +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 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 (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); + } + return name; + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index d31ffacab..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; @@ -696,22 +686,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 +800,35 @@ 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 + * 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, 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() && 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+"); + } + 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 (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)))); @@ -958,10 +950,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 +969,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 +1000,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..89363dd0c 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); @@ -924,31 +930,17 @@ 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. + // 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); } /** @@ -1320,12 +1312,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 +1330,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 +1355,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 +1372,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/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/control/ControlClient.java b/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java index 0213fafec..adf42b0c6 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; @@ -10,12 +11,12 @@ 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; 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 +72,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 +116,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 +167,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,100 +205,70 @@ 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; + if (timeout.isZero() || timeout.isNegative()) throw new IllegalArgumentException("timeout is not positive"); + ControlAcquisition acquisition = ControlAcquisition.acquire(carrier, config, timeout); 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; - 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(); + // 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; + } 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) { - // 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}"); - 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) { - requestJsonLayouts(); LOG.log(System.Logger.Level.DEBUG, "tmux control client attached to session {0}", session.value()); } @@ -302,8 +283,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 +330,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 +468,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 +535,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..c8bd92fd2 --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/internal/ControlAcquisition.java @@ -0,0 +1,204 @@ +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.Capabilities; +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.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},#{pid},#{start_time},'#{s/'/'\\\\''/:socket_path}'"; + 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 Capabilities.CONTROL_MODE.availableOn(version); + } + + /** + * 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); + } + } + + /** + * 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("run-shell", "-C", marker + 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; + } + 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()), + "run-shell", + result.exitCode(), + result.stderr()); + } + 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]); + 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); + } + } + + /** 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; + } + + 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/internal/TmuxFeatures.java b/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java new file mode 100644 index 000000000..6a041a06c --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/internal/TmuxFeatures.java @@ -0,0 +1,166 @@ +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 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 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() && !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') && !Capabilities.BUFFER_DELETE_NAMED.availableOn(version)) { + throw new UnsupportedFeatureException( + "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') && !Capabilities.BUFFER_RENAME.availableOn(version)) { + throw new UnsupportedFeatureException( + "renaming a buffer by exact name", Capabilities.BUFFER_RENAME.since(), version); + } + } else if (matches(verb, "split-window", "splitw", 2)) { + 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) + && 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+"); + } 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) + && !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')) { + 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); + } + } + } + } + } + } + + /** 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) != ',') { + 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/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/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) { 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/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"))); + } +} diff --git a/libtmux/src/test/java/io/github/libtmux/ServerTest.java b/libtmux/src/test/java/io/github/libtmux/ServerTest.java index 5063fdb04..3e706f6ba 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" -> @@ -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 b83aa8ea0..9d9a03a21 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,18 @@ void extendedOutputIsOutput(@TempDir Path directory) throws Exception { printf 'unknown command: %s3.7d\\n' "${argument%'#{version}'}" >&2 exit 1;; esac + case "$argument" in + libtmux-identity-*) + printf 'unknown command: %s3.7d,4242,10,/fake-control\\n' "${argument%%'#{version}'*}" >&2 + exit 1;; + esac done - read_request() { IFS= read -r request && IFS= read -r marker; } + read_request() { + while IFS= read -r request && IFS= read -r marker; do + return 0 + done + return 1 + } answer() { printf '%%begin 1 1 1\\n' for line in "$@"; do printf '%s\\n' "$line"; done @@ -519,6 +528,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); 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); + } +} 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() } }