Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/tmux-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 59 additions & 9 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<!-- snippet: scala-sync: root-ox-live-view -->
<!-- snippet: scala-sync-control: root-ox-live-view -->
```scala
import _root_.ox.{supervised, useCloseableInScope}
import io.github.libtmux.scaladsl.{config => _, *}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import org.gradle.api.tasks.TaskAction
* Each fence carries a directive on the line before it, `<!-- snippet: scala-MODE: id | detail -->`:
* `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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" +
Expand Down Expand Up @@ -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("^<!--\\s*snippet:\\s*scala-(sync|io|reject|build):\\s*([a-z0-9][a-z0-9-]*)(?:\\s*\\|\\s*(.+?))?\\s*-->$")
val DIRECTIVE = Regex("^<!--\\s*snippet:\\s*scala-(sync|sync-control|io|io-control|reject|build):\\s*([a-z0-9][a-z0-9-]*)(?:\\s*\\|\\s*(.+?))?\\s*-->$")

fun relative(root: File, file: File): String = root.toPath().relativize(file.toPath()).toString().replace('\\', '/')

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading