From 12388e9626ea56f22f3fc15f2c211bcf09123089 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 14:31:32 -0500 Subject: [PATCH 01/11] Clojure(feat[facade]): Add native Clojure facade why: Provide captured tmux values, explicit effects, bounded async work, and stream adaptation without duplicating the Java engine. what: - Publish core and optional core.async Clojure modules through the BOM. - Add captured snapshots, bounded tasks, control streams, and byte events. - Test owned real-tmux fixtures, documentation fences, artifacts, and Clojure boundary contracts. --- CHANGELOG.md | 19 + MIGRATION.md | 23 + .../kotlin/libtmux.clojure-library.gradle.kts | 166 ++++++ build.gradle.kts | 40 +- deps.edn | 24 + docs-tests/build.gradle.kts | 9 +- .../libtmux/docs/DocumentationFactsTest.java | 17 +- docs/guide/clojure-reference.md | 145 +++++ docs/guide/clojure.md | 336 ++++++++++++ .../libtmux/internal/loadguard/LoadGuard.java | 43 ++ .../internal/loadguard/NamespaceAgent.java | 121 ++++ .../internal/loadguard/ResourceProbe.java | 49 ++ .../libtmux/internal/guard_probe.clj | 15 + .../libtmux/internal/test_runner.clj | 126 +++++ gradle/libs.versions.toml | 6 + integration-tests/build.gradle.kts | 89 +++ .../libtmux/async_integration_test.clj | 90 +++ .../libtmux/control_integration_test.clj | 257 +++++++++ .../libtmux/core_async_integration_test.clj | 47 ++ .../clojure/libtmux/core_integration_test.clj | 258 +++++++++ .../clojure/libtmux/data_integration_test.clj | 113 ++++ .../libtmux/documentation_examples_test.clj | 207 +++++++ .../test/clojure/libtmux/internal/fixture.clj | 184 +++++++ .../clojure/libtmux/internal/fixture_test.clj | 163 ++++++ .../it/CapturedServerIntegrationTest.java | 73 +++ .../it/ControlBytesIntegrationTest.java | 80 +++ libtmux-bom/README.md | 2 + libtmux-bom/build.gradle.kts | 2 + libtmux-clojure-core-async/README.md | 25 + libtmux-clojure-core-async/build.gradle.kts | 13 + .../src/main/clojure/libtmux/core_async.clj | 136 +++++ .../test/clojure/libtmux/core_async_test.clj | 88 +++ .../internal/core_async_package_test.clj | 9 + libtmux-clojure/README.md | 92 ++++ libtmux-clojure/build.gradle.kts | 13 + .../src/main/clojure/libtmux/async.clj | 331 +++++++++++ .../src/main/clojure/libtmux/control.clj | 245 +++++++++ .../src/main/clojure/libtmux/core.clj | 518 ++++++++++++++++++ .../src/main/clojure/libtmux/data.clj | 232 ++++++++ .../src/test/clojure/libtmux/async_test.clj | 390 +++++++++++++ .../src/test/clojure/libtmux/control_test.clj | 211 +++++++ .../src/test/clojure/libtmux/core_test.clj | 133 +++++ .../src/test/clojure/libtmux/data_test.clj | 73 +++ .../clojure/libtmux/internal/package_test.clj | 55 ++ .../io/github/libtmux/CapturedServer.java | 68 +++ .../main/java/io/github/libtmux/Server.java | 28 + .../github/libtmux/control/ControlClient.java | 76 ++- .../libtmux/control/ControlLineReader.java | 39 +- .../libtmux/control/EventSubscription.java | 121 +++- .../libtmux/control/PaneOutputBytes.java | 65 +++ .../libtmux/control/PaneOutputDecoder.java | 85 +++ .../io/github/libtmux/CapturedServerTest.java | 101 ++++ .../java/io/github/libtmux/ServerTest.java | 37 ++ .../libtmux/control/ControlClientTest.java | 57 ++ .../control/ControlLineReaderTest.java | 9 +- .../libtmux/control/PaneOutputBytesTest.java | 187 +++++++ settings.gradle.kts | 2 + 57 files changed, 6107 insertions(+), 36 deletions(-) create mode 100644 build-logic/src/main/kotlin/libtmux.clojure-library.gradle.kts create mode 100644 deps.edn create mode 100644 docs/guide/clojure-reference.md create mode 100644 docs/guide/clojure.md create mode 100644 gradle/clojure-runner/agent/libtmux/internal/loadguard/LoadGuard.java create mode 100644 gradle/clojure-runner/agent/libtmux/internal/loadguard/NamespaceAgent.java create mode 100644 gradle/clojure-runner/agent/libtmux/internal/loadguard/ResourceProbe.java create mode 100644 gradle/clojure-runner/libtmux/internal/guard_probe.clj create mode 100644 gradle/clojure-runner/libtmux/internal/test_runner.clj create mode 100644 integration-tests/src/test/clojure/libtmux/async_integration_test.clj create mode 100644 integration-tests/src/test/clojure/libtmux/control_integration_test.clj create mode 100644 integration-tests/src/test/clojure/libtmux/core_async_integration_test.clj create mode 100644 integration-tests/src/test/clojure/libtmux/core_integration_test.clj create mode 100644 integration-tests/src/test/clojure/libtmux/data_integration_test.clj create mode 100644 integration-tests/src/test/clojure/libtmux/documentation_examples_test.clj create mode 100644 integration-tests/src/test/clojure/libtmux/internal/fixture.clj create mode 100644 integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj create mode 100644 integration-tests/src/test/java/io/github/libtmux/it/CapturedServerIntegrationTest.java create mode 100644 integration-tests/src/test/java/io/github/libtmux/it/ControlBytesIntegrationTest.java create mode 100644 libtmux-clojure-core-async/README.md create mode 100644 libtmux-clojure-core-async/build.gradle.kts create mode 100644 libtmux-clojure-core-async/src/main/clojure/libtmux/core_async.clj create mode 100644 libtmux-clojure-core-async/src/test/clojure/libtmux/core_async_test.clj create mode 100644 libtmux-clojure-core-async/src/test/clojure/libtmux/internal/core_async_package_test.clj create mode 100644 libtmux-clojure/README.md create mode 100644 libtmux-clojure/build.gradle.kts create mode 100644 libtmux-clojure/src/main/clojure/libtmux/async.clj create mode 100644 libtmux-clojure/src/main/clojure/libtmux/control.clj create mode 100644 libtmux-clojure/src/main/clojure/libtmux/core.clj create mode 100644 libtmux-clojure/src/main/clojure/libtmux/data.clj create mode 100644 libtmux-clojure/src/test/clojure/libtmux/async_test.clj create mode 100644 libtmux-clojure/src/test/clojure/libtmux/control_test.clj create mode 100644 libtmux-clojure/src/test/clojure/libtmux/core_test.clj create mode 100644 libtmux-clojure/src/test/clojure/libtmux/data_test.clj create mode 100644 libtmux-clojure/src/test/clojure/libtmux/internal/package_test.clj create mode 100644 libtmux/src/main/java/io/github/libtmux/CapturedServer.java create mode 100644 libtmux/src/main/java/io/github/libtmux/control/PaneOutputBytes.java create mode 100644 libtmux/src/main/java/io/github/libtmux/control/PaneOutputDecoder.java create mode 100644 libtmux/src/test/java/io/github/libtmux/CapturedServerTest.java create mode 100644 libtmux/src/test/java/io/github/libtmux/control/PaneOutputBytesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f9be79..1acb90c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,25 @@ production. ## Unreleased +### Added + +- **`Server.capture` returns handles from one captured graph.** Sessions, + window links, pane occurrences, clients, and metadata share the same capture; + traversing them issues no additional commands. + +- **Control output can retain its original bytes.** + `ControlClient.subscribeOutputBytes` exposes bounded byte subscriptions; + `PaneOutputDecoder` decodes UTF-8 across chunks with an explicit malformed + input policy. `EventSubscription.nextDelivery` associates each event with + its loss counters. + +### Changed + +- **Control subscriptions report why they ended and retain final output.** + Remote termination leaves queued events available to drain. Subscriptions + now bound payload bytes as well as event count; see + [migration guidance](MIGRATION.md#control-subscriptions-bound-bytes-and-report-termination). + ## 0.0.1-alpha.12 — 2026-09-19 ### Added diff --git a/MIGRATION.md b/MIGRATION.md index c021fafd..ed4a4050 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -5,6 +5,29 @@ API changes that require updates to calling code are recorded here. See ## Next release +### Control subscriptions bound bytes and report termination + +Existing `ControlClient.subscribeOutput` and `subscribeEvents` subscriptions +now retain at most 16 MiB of payload, in addition to their event limit. When a +new event exceeds the byte budget, older events are dropped until it fits. An +event larger than the entire budget is dropped after clearing queued events. +`droppedCount()` and `droppedBytes()` expose this loss. Use `nextDelivery()` to +read an event and its loss counters together. + +Use `subscribeOutputBytes(capacity, maxBytes)` when original pane bytes matter. +The existing string events retain their per-chunk decoding behavior and cannot +reconstruct UTF-8 split between notifications. Pass raw deliveries to +`PaneOutputDecoder`, choosing `CodingErrorAction.REPORT` or `REPLACE`, for +incremental text. At stream end, call `finish(paneId)` for each observed pane +to handle an incomplete trailing character. Loss resets partial characters. + +`termination()` preserves the first observed cause. `CONTROL_EXIT` means the +control attachment ended; it does not prove the daemon died. Raw EOF with no +known cause remains `UNKNOWN`. Remote termination retains queued events, so +drain until `nextDelivery()` returns empty before processing the terminal +result. Explicit subscription `close()` discards its buffer and wakes readers. +Those intentional discards are separate from overflow counts. + ### A tmux release candidate keeps its name and counts as its release `TmuxVersion` now carries the pre-release a version named, so a server running diff --git a/build-logic/src/main/kotlin/libtmux.clojure-library.gradle.kts b/build-logic/src/main/kotlin/libtmux.clojure-library.gradle.kts new file mode 100644 index 00000000..b11cb0eb --- /dev/null +++ b/build-logic/src/main/kotlin/libtmux.clojure-library.gradle.kts @@ -0,0 +1,166 @@ +import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.jvm.toolchain.JavaToolchainService + +plugins { id("libtmux.published-library") } + +val clojureLibraries = extensions.getByType().named("libs") +val clojureSourceSets = extensions.getByType() +val clojureJavaToolchains = extensions.getByType() +val clojureJavaVersion = providers.gradleProperty("libtmuxJavaVersion").getOrElse("21").toInt() +val clojureVersion = + providers.gradleProperty("libtmuxClojureVersion") + .getOrElse(clojureLibraries.findVersion("clojure").orElseThrow().requiredVersion) + +clojureSourceSets.named("main") { resources.srcDir("src/main/clojure") } +clojureSourceSets.named("test") { + resources.srcDir("src/test/clojure") + resources.srcDir(rootProject.file("gradle/clojure-runner")) + resources.exclude("agent/**") +} + +dependencies { "api"("org.clojure:clojure:$clojureVersion") } + +val namespaceGuardAsm = configurations.create("clojureNamespaceGuardAsm") { + isCanBeConsumed = false + isCanBeResolved = true +} +dependencies { add(namespaceGuardAsm.name, "org.ow2.asm:asm:9.9.1") } +val compileNamespaceGuard = tasks.register("compileClojureNamespaceGuard") { + source(rootProject.fileTree("gradle/clojure-runner/agent") { include("**/*.java") }) + classpath = namespaceGuardAsm + destinationDirectory.set(layout.buildDirectory.dir("namespace-guard/classes")) + javaCompiler.set(clojureJavaToolchains.compilerFor { + languageVersion.set(JavaLanguageVersion.of(21)) + }) + options.release.set(21) +} +val namespaceGuardBootstrap = tasks.register("clojureNamespaceGuardBootstrap") { + from(compileNamespaceGuard.flatMap { it.destinationDirectory }) { + include("libtmux/internal/loadguard/LoadGuard.class") + } + archiveFileName.set("namespace-guard-bootstrap.jar") + destinationDirectory.set(layout.buildDirectory.dir("namespace-guard")) +} +val namespaceGuardAsmJar = tasks.register("clojureNamespaceGuardAsm") { + from(provider { namespaceGuardAsm.map { zipTree(it) } }) { + exclude("META-INF/MANIFEST.MF", "module-info.class") + } + archiveFileName.set("namespace-guard-asm.jar") + destinationDirectory.set(layout.buildDirectory.dir("namespace-guard")) +} +val namespaceGuardAgent = tasks.register("clojureNamespaceGuardAgent") { + from(compileNamespaceGuard.flatMap { it.destinationDirectory }) { + exclude("libtmux/internal/loadguard/LoadGuard.class") + } + manifest.attributes( + "Premain-Class" to "libtmux.internal.loadguard.NamespaceAgent", + "Can-Retransform-Classes" to "true", + ) + archiveFileName.set("namespace-guard-agent.jar") + destinationDirectory.set(layout.buildDirectory.dir("namespace-guard")) +} + +val declaredClojureTests = provider { extra["clojureTestNamespaces"] as String } +val discoveredClojureTests = provider { + fileTree("src/test/clojure") { include("**/*_test.clj") } + .files + .map { source -> + source.relativeTo(file("src/test/clojure")) + .invariantSeparatorsPath + .removeSuffix(".clj") + .replace('_', '-') + .replace('/', '.') + } + .sorted() +} + +val clojureNamespaceLoad = tasks.register("clojureNamespaceLoad") { + group = "verification" + description = "Loads public Clojure namespaces without reflection warnings or resource creation." + dependsOn(tasks.named("testClasses"), namespaceGuardAgent, namespaceGuardBootstrap, namespaceGuardAsmJar) + classpath = clojureSourceSets.named("test").get().runtimeClasspath + mainClass.set("clojure.main") + args("-m", "libtmux.internal.test-runner", "load") + jvmArgs("-Dclojure.main.report=stderr", "-Xshare:off") + javaLauncher.set(clojureJavaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(clojureJavaVersion)) + }) + doFirst { + jvmArgs("-javaagent:${namespaceGuardAgent.get().archiveFile.get().asFile}=" + + namespaceGuardBootstrap.get().archiveFile.get().asFile + File.pathSeparator + + namespaceGuardAsmJar.get().archiveFile.get().asFile) + } +} + +val clojureNamespaceGuardTest = tasks.register("clojureNamespaceGuardTest") { + group = "verification" + description = "Proves namespace loading rejects executor, process and socket creation." + dependsOn(tasks.named("testClasses"), namespaceGuardAgent, namespaceGuardBootstrap, namespaceGuardAsmJar) + classpath = clojureSourceSets.named("test").get().runtimeClasspath + mainClass.set("clojure.main") + args("-m", "libtmux.internal.test-runner", "guard-probes") + jvmArgs("-Xshare:off") + javaLauncher.set(clojureJavaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(clojureJavaVersion)) + }) + doFirst { + jvmArgs("-javaagent:${namespaceGuardAgent.get().archiveFile.get().asFile}=" + + namespaceGuardBootstrap.get().archiveFile.get().asFile + File.pathSeparator + + namespaceGuardAsmJar.get().archiveFile.get().asFile) + } +} + +val clojureTest = tasks.register("clojureTest") { + group = "verification" + description = "Runs the module's native Clojure unit tests." + dependsOn(tasks.named("testClasses")) + classpath = clojureSourceSets.named("test").get().runtimeClasspath + mainClass.set("clojure.main") + args("-m", "libtmux.internal.test-runner", "test") + jvmArgs("-Dclojure.main.report=stderr") + javaLauncher.set(clojureJavaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(clojureJavaVersion)) + }) + doFirst { + systemProperty("libtmux.clojure.test-namespaces", declaredClojureTests.get()) + } +} + +val clojureTestInventory = tasks.register("clojureTestInventory") { + group = "verification" + description = "Fails when a native Clojure test namespace is omitted from the runner." + inputs.files(fileTree("src/test/clojure") { include("**/*_test.clj") }) + doLast { + val declared = declaredClojureTests.get().split(',').sorted() + require(declared == discoveredClojureTests.get()) { + "configured Clojure tests $declared do not match source tests ${discoveredClojureTests.get()}" + } + } +} + +val prepareClojureTestClasspath = tasks.register("prepareClojureTestClasspath") { + group = "build setup" + description = "Writes the reusable native Clojure test classpath." + dependsOn(tasks.named("testClasses")) + val destination = layout.buildDirectory.file("clojure/test-classpath.txt") + inputs.files(clojureSourceSets.named("test").map { it.runtimeClasspath }) + outputs.file(destination) + doLast { + destination.get().asFile.apply { + parentFile.mkdirs() + writeText(clojureSourceSets.named("test").get().runtimeClasspath.asPath + "\n") + } + } +} + +tasks.named("check") { + dependsOn(clojureNamespaceLoad, clojureNamespaceGuardTest, clojureTest, clojureTestInventory) +} + +tasks.withType().configureEach { + isPreserveFileTimestamps = false + isReproducibleFileOrder = true + dirPermissions { unix("755") } + filePermissions { unix("644") } +} diff --git a/build.gradle.kts b/build.gradle.kts index 4df0a756..e6966ce4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -102,9 +102,47 @@ val kotlinStaysDownstream = } } +val clojureDependencyMetadata = + tasks.register("clojureDependencyMetadata") { + group = "verification" + description = "Checks deps.edn against the checked-in development dependency baseline." + val depsEdn = layout.projectDirectory.file("deps.edn") + val gradleProperties = layout.projectDirectory.file("gradle.properties") + inputs.files(depsEdn, gradleProperties) + doLast { + val declared = depsEdn.asFile.readText() + val clojureVersion = libs.versions.clojure.get() + val coreAsyncVersion = libs.versions.core.async.get() + val developmentVersion = + gradleProperties.asFile.useLines { lines -> + lines.first { it.startsWith("libtmuxVersion=") }.substringAfter('=') + } + val expected = + mapOf( + "org.clojure/clojure" to clojureVersion, + "org.clojure/core.async" to coreAsyncVersion, + "io.github.libtmux/libtmux" to developmentVersion, + "io.github.libtmux/libtmux-junit5" to developmentVersion, + ) + expected.forEach { (coordinate, version) -> + val declaredVersions = + Regex("${Regex.escape(coordinate)}\\s+\\{:mvn/version\\s+\"([^\"]+)\"") + .findAll(declared) + .map { it.groupValues[1] } + .toList() + require(declaredVersions.isNotEmpty()) { + "deps.edn must declare $coordinate at the development baseline $version" + } + require(declaredVersions.all { it == version }) { + "every deps.edn declaration of $coordinate must use the development baseline $version; found $declaredVersions" + } + } + } + } + tasks.register("check") { group = "verification" description = "Every gate that must hold before publication." dependsOn(subprojects.filter { it.buildFile.exists() }.map { "${it.path}:check" }) - dependsOn(platformCoversEveryPublishedModule, kotlinStaysDownstream) + dependsOn(platformCoversEveryPublishedModule, kotlinStaysDownstream, clojureDependencyMetadata) } diff --git a/deps.edn b/deps.edn new file mode 100644 index 00000000..0e7a9e0c --- /dev/null +++ b/deps.edn @@ -0,0 +1,24 @@ +{:paths ["libtmux-clojure/src/main/clojure"] + :deps {org.clojure/clojure {:mvn/version "1.12.4"} + io.github.libtmux/libtmux {:mvn/version "0.0.1-alpha.12-SNAPSHOT"}} + :aliases + {:test + {:extra-paths ["libtmux-clojure/src/test/clojure" + "gradle/clojure-runner"] + :main-opts ["-m" "libtmux.internal.test-runner" "test" + "libtmux-clojure/src/test/clojure"]} + :adapter-test + {:extra-paths ["libtmux-clojure-core-async/src/main/clojure" + "libtmux-clojure-core-async/src/test/clojure" + "gradle/clojure-runner"] + :extra-deps {org.clojure/core.async {:mvn/version "1.8.741"}} + :main-opts ["-m" "libtmux.internal.test-runner" "test" + "libtmux-clojure-core-async/src/test/clojure"]} + :integration-test + {:extra-paths ["integration-tests/src/test/clojure" + "libtmux-clojure-core-async/src/main/clojure" + "gradle/clojure-runner"] + :extra-deps {io.github.libtmux/libtmux-junit5 {:mvn/version "0.0.1-alpha.12-SNAPSHOT"} + org.clojure/core.async {:mvn/version "1.8.741"}} + :main-opts ["-m" "libtmux.internal.test-runner" "test" + "integration-tests/src/test/clojure"]}}} diff --git a/docs-tests/build.gradle.kts b/docs-tests/build.gradle.kts index b9b10abd..5ad59a9a 100644 --- a/docs-tests/build.gradle.kts +++ b/docs-tests/build.gradle.kts @@ -40,7 +40,14 @@ tasks.withType().configureEach { // date, exactly as the snippets once did. val sources = rootProject.fileTree(root) { - include("*/src/main/**/*.java", "*/src/main/**/*.kt", "*/src/test/**/*.java", "*/src/test/**/*.kt") + include( + "*/src/main/**/*.java", + "*/src/main/**/*.kt", + "*/src/main/**/*.clj", + "*/src/test/**/*.java", + "*/src/test/**/*.kt", + "*/src/test/**/*.clj", + ) exclude("**/build/**") } diff --git a/docs-tests/src/test/java/io/github/libtmux/docs/DocumentationFactsTest.java b/docs-tests/src/test/java/io/github/libtmux/docs/DocumentationFactsTest.java index 2fec2ca0..5980121f 100644 --- a/docs-tests/src/test/java/io/github/libtmux/docs/DocumentationFactsTest.java +++ b/docs-tests/src/test/java/io/github/libtmux/docs/DocumentationFactsTest.java @@ -38,6 +38,8 @@ final class DocumentationFactsTest { "libtmux-jackson", "libtmux-junit5", "libtmux-kotlin", + "libtmux-clojure", + "libtmux-clojure-core-async", "libtmux-mcp", "libtmux-workspace", "benchmarks", @@ -47,7 +49,14 @@ final class DocumentationFactsTest { /** Published modules, which is what a reader is told to depend on. */ private static final List PUBLISHED = List.of( - "libtmux", "libtmux-jackson", "libtmux-junit5", "libtmux-kotlin", "libtmux-mcp", "libtmux-workspace"); + "libtmux", + "libtmux-jackson", + "libtmux-junit5", + "libtmux-kotlin", + "libtmux-clojure", + "libtmux-clojure-core-async", + "libtmux-mcp", + "libtmux-workspace"); /** The parity inventories, whose every row names the contract test that row will need. */ private static final List PARITY = List.of("docs/parity/python-api.md", "docs/parity/test-map.md"); @@ -62,11 +71,11 @@ final class DocumentationFactsTest { Pattern.compile("libtmux[a-z0-9-]*\\s*([0-9][^<]*)"), Pattern.compile("io\\.github\\.libtmux:[a-z0-9-]+ -> ([0-9][^\\s)]*)")); - /** Fences this build compiles and runs. A fence in any other source language is not checked. */ - private static final Set EXECUTED = Set.of("java", "kotlin"); + /** Fences an owned documentation gate compiles and runs. */ + private static final Set EXECUTED = Set.of("java", "kotlin", "clojure"); /** Source languages a guide may reasonably carry, whether or not anything here builds them. */ - private static final Set SOURCE = Set.of("java", "kotlin", "scala", "groovy"); + private static final Set SOURCE = Set.of("java", "kotlin", "clojure", "scala", "groovy"); private static String read(String path) { try { diff --git a/docs/guide/clojure-reference.md b/docs/guide/clojure-reference.md new file mode 100644 index 00000000..60e64828 --- /dev/null +++ b/docs/guide/clojure-reference.md @@ -0,0 +1,145 @@ +# Clojure captured-value reference + +`libtmux.data/from-capture` creates every value in this reference. `snapshot!` +and the `*!` listing functions acquire one captured graph; `data/windows`, +`data/panes`, `data/active-window` and `data/active-pane` traverse it without +I/O. Scalar access is ordinary Clojure keyword lookup and is also local. + +Every owning map has its documented key, even when the value is `nil` because +tmux did not report an optional value. A missing relationship means the parent +or target was absent from an otherwise successful capture. Failed acquisition +throws; it is never an empty graph. + +`data/data` removes every `:tmux/ref`. The ref is the only effect authority; +editing `:session/id`, `:window/id` or `:pane/id` changes a map, not tmux. +`data/same-entity?` compares `:tmux/identity`. `data/same-link?` also compares +the window placement in `:tmux/link`; it does not establish a daemon generation +or detect an unseen identical unlink-and-relink replacement. + + + +## Shared keys + +- `:tmux/ref` is the Java handle. `data/data` removes it; effects and + `FilterExpr` need it, while identity does not. +- `:tmux/identity` is always-present endpoint, server-PID, kind and physical-ID + data. `same-entity?` compares it. +- `:tmux/link` exists only on windows and panes. It holds session, window and + index occurrence context; `same-link?` compares it. +- `:tmux/realm` and `:tmux/server` are always-present identity text. Neither + has a Java filter field. +- `:tmux/pid` is an integer or `nil`; the key stays present when tmux does not + report a daemon or pane PID. It has no Java filter field. +- `:tmux/kind` is always-present `:server`, `:session`, `:window`, `:pane` or + `:client` identity data. It has no Java filter field. +- `:tmux/id` is always-present physical identity text. Editing it cannot + retarget an effect, and it has no Java filter field. +- `:tmux/captured-at` is a root-only `Instant`; `data/data` writes ISO-8601 + text. `:tmux/version` is root-only text or `nil` when unavailable. Neither + has a Java filter field. + +## Root and sessions + +- `:tmux/sessions`, `:tmux/windows`, `:tmux/panes` and `:tmux/clients` are + root-only captured vectors. They have no root Java relation. +- `:session/id` and `:session/name` are always-present text. `Session_/id` and + `Session_/name` support Java filtering. +- `:session/attached?` is an always-present Boolean. `Session_/attached` + supports Java filtering. +- `:session/windows` is an always-present local vector relation. `data/windows` + traverses it and `Session_/windows` is the canonical Java relation. + +## Windows and panes + +- `:window/id` is always-present underlying-window text across links. + `Window_/id` supports Java filtering. +- `:window/index` is the always-present index within `:tmux/link`. + `Window_/index` supports Java filtering. +- `:window/name` is always-present text. `Window_/name` supports Java + filtering. +- `:window/active?` is an always-present Boolean for this link's session. + `Window_/active` supports Java filtering. +- `:window/linked?` is an always-present Boolean stating that the underlying + window has another link. `Window_/linked` supports Java filtering. +- `:window/width` and `:window/height` are always-present captured cell sizes; + `:window/layout` is always-present layout text. They have no Java fields. +- `:window/panes` is an always-present local vector relation. `data/panes` + traverses it and `Window_/panes` is the canonical Java relation. +- `:pane/id` is always-present physical-pane text; `Pane_/id` supports Java + filtering. `:pane/index` is an always-present window index; `Pane_/index` + supports Java filtering. +- `:pane/active?` is an always-present Boolean for the captured window. + `Pane_/active` supports Java filtering. +- `:pane/current-command` and `:pane/current-path` are always-present tmux + text. `Pane_/command` and `Pane_/path` support Java filtering. +- `:pane/floating?` is Boolean or `nil` when tmux does not report it. The key + stays present and it has no Java field. +- `:pane/width` and `:pane/height` are always-present cell sizes; `Pane_/width` + and `Pane_/height` support Java filtering. +- `:pane/left` and `:pane/top` are always-present captured positions; + `Pane_/left` and `Pane_/top` support Java filtering. +- `:pane/title` is always-present text; `Pane_/title` supports Java filtering. +- `:pane/pid` is an integer or `nil` when tmux does not report it. The key stays + present and it has no Java field. +- `:pane/edges` is an always-present map with unqualified Boolean edge keys. + It has no Java field. + +## Clients + +- `:client/name` is always-present tmux client text; `Client_/name` supports + Java filtering. +- `:client/session` is a session map or `nil` when no session is attached. The + key stays present and `Client_/session` is the canonical Java relation. +- `:client/active-window` and `:client/active-pane` are a captured map or + `nil` when no attached session supplies them. Their keys stay present and + they have no Java fields. + +Java expressions always evaluate the preserved Java ref through +`data/predicate` or `data/matching`; they never evaluate edited or detached +maps. `Pane_` has no registered pane-to-window relation, so use captured +traversal for that direction instead of inventing a canonical Java relation. diff --git a/docs/guide/clojure.md b/docs/guide/clojure.md new file mode 100644 index 00000000..1f1a5f82 --- /dev/null +++ b/docs/guide/clojure.md @@ -0,0 +1,336 @@ +# Clojure + +`libtmux-clojure` is a JVM façade over the Java library. It keeps tmux command +encoding, transport, server identity and compatibility in Java, while exposing +captured persistent Clojure values and ordinary collection operations. + +Every Clojure fence in reader-facing documentation is discovered and run against +an owned tmux server. A fence must open and close its own client with +`with-open` +and end with one `;; =>` EDN result. The test fixture supplies +`libtmux.docs.socket` and `libtmux.docs.binary`; applications supply their +socket path and use their selected binary or the default `tmux` command. + +## Values, links and projection + +`sessions!`, `windows!`, `panes!` and `clients!` acquire a graph. `data/windows` +and `data/panes` traverse that graph without I/O. A window occurrence has a +contextual `:tmux/link`; `data/same-link?` includes the session, window and +index, while `data/same-entity?` compares only the physical identity. + +`nil` means an optional relationship or lookup was absent in a successful +capture. Failed acquisition throws an `ExceptionInfo`; it is never represented +as an empty collection. `data/data` returns a detached EDN projection without +the Java refs required for effects or Java filter evaluation. + + +```clojure +(require '[libtmux.core :as tm] + '[libtmux.data :as data]) + +(let [socket-path (java.nio.file.Path/of + (System/getProperty "libtmux.docs.socket") + (make-array String 0))] + (with-open [server (tm/open! {:socket-path socket-path})] + (let [session (tm/new-session! server {:name "docs-projection"})] + (data/data (select-keys session [:tmux/ref :session/name]))))) +;; => {:session/name "docs-projection"} +``` + +## Native filtering and Java expressions + +Use `filter`, `map`, `into` and transducers for unrestricted local Clojure +logic. Java `FilterExpr` remains available when an expression must use its +canonical Java fields and relationship semantics. `data/predicate` returns an +IFn. `data/matching` returns a transducer with one argument and a lazy sequence +with an expression and collection. Both evaluate preserved `:tmux/ref` values, +not fields edited in a Clojure map. + + +```clojure +(require '[libtmux.core :as tm] + '[libtmux.data :as data]) +(import '[io.github.libtmux Session_]) + +(let [socket-path (java.nio.file.Path/of + (System/getProperty "libtmux.docs.socket") + (make-array String 0))] + (with-open [server (tm/open! {:socket-path socket-path})] + (tm/new-session! server {:name "docs-query"}) + (->> (tm/sessions! server) + (data/matching (.is (Session_/name) "docs-query")) + (mapv :session/name)))) +;; => ["docs-query"] +``` + +Use `data/exactly-one` when zero or many candidates is an error, and +`data/one-or-none` when absence is allowed. Establish cardinality before any +effect. + +## Effects, errors and execution modes + +Every effect in `libtmux.core` takes a Java ref from `:tmux/ref`; a captured map +is not an editable remote object. `new-session!`, `rename!`, `split-pane!` and +`refresh!` return new captured values when they can recapture the result. Void +operations return `nil`. + +Validation errors include `:tmux/phase :validation` and +`:tmux/dispatch :not-dispatched`. Transport and mutation failures retain the +operation, dispatch certainty and whether effects are possible. Inspect the +stable subset with `core/error-summary`; do not parse exception messages. + +`batch!` and `chain!` use fresh Java groups. Their per-position results can be +`:complete`, `:failed`, `:skipped` or `:unknown`; neither operation promises a +transaction or rollback. A group that can wait needs `{:kind :wait}` so its +release can reserve capacity. + +### Bounded capture work and cancellation + +`libtmux.async/runtime!` bounds asynchronous work. Submit each capture as a +task, then dereference its result after the runtime has admitted it. Task +submission does not add another tmux transport or alter the captured values. + + +```clojure +(require '[libtmux.async :as async] + '[libtmux.core :as tm]) + +(let [socket-path (java.nio.file.Path/of + (System/getProperty "libtmux.docs.socket") + (make-array String 0))] + (with-open [server (tm/open! {:socket-path socket-path}) + runtime (async/runtime! {:max-running 3})] + (tm/new-session! server {:name "docs-parallel"}) + (let [tasks (mapv (fn [_] (async/submit! runtime #(tm/snapshot! server))) + (range 2))] + (mapv #(boolean (some (fn [session] + (= "docs-parallel" (:session/name session))) + (:tmux/sessions @%))) + tasks)))) +;; => [true true] +``` + +`cancel!` is explicit. A cancellation accepted after a task starts reports +unknown dispatch certainty, because the enclosing tmux operation might have +started. Use a separate `:wait` task for operations that wait for a release. + + +```clojure +(require '[libtmux.async :as async] + '[libtmux.core :as tm]) +(import '[java.time Duration]) + +(let [socket-path (java.nio.file.Path/of + (System/getProperty "libtmux.docs.socket") + (make-array String 0))] + (with-open [server (tm/open! {:socket-path socket-path}) + runtime (async/runtime! {})] + (let [started (promise) + task (async/submit! runtime {:kind :wait} + #(do (deliver started true) + (tm/await-channel! server "docs-cancel" + (Duration/ofSeconds 20))))] + @started + (async/cancel! task) + (try + @task + (catch clojure.lang.ExceptionInfo failure + [(:tmux/error (ex-data failure)) + (:tmux/dispatch (ex-data failure))]))))) +;; => [:cancelled :unknown] +``` + +### Layouts and literal input + +Creation and layout functions modify tmux and return fresh observations when +they create a target. `apply-layout!` accepts tmux's explicit layout text. +`send-literal!` writes characters without treating them as tmux key names; +`send-keys!` takes an explicit key vector. Their complete return does not mean +the program in the pane has finished processing input. + + +```clojure +(require '[libtmux.core :as tm] + '[libtmux.data :as data]) + +(let [socket-path (java.nio.file.Path/of + (System/getProperty "libtmux.docs.socket") + (make-array String 0))] + (with-open [server (tm/open! {:socket-path socket-path})] + (let [session (tm/new-session! server {:name "docs-layout"}) + window (tm/new-window! (:tmux/ref session) + {:name "docs-layout-window" :detached? true}) + pane (first (data/panes window)) + _ (tm/apply-layout! (:tmux/ref window) (:window/layout window)) + split (tm/split-pane! (:tmux/ref pane) + {:direction :right :percent 30})] + [(:window/name window) (= (:tmux/link pane) (:tmux/link split))]))) +;; => ["docs-layout-window" true] +``` + + +```clojure +(require '[clojure.string :as str] + '[libtmux.core :as tm] + '[libtmux.data :as data]) + +(let [socket-path (java.nio.file.Path/of + (System/getProperty "libtmux.docs.socket") + (make-array String 0))] + (with-open [server (tm/open! {:socket-path socket-path})] + (let [session (tm/new-session! server {:name "docs-literal"}) + pane (first (data/panes (first (data/windows session))))] + (tm/send-literal! (:tmux/ref pane) "printf docs-literal") + (tm/send-keys! (:tmux/ref pane) ["Enter"]) + (boolean (some #(str/includes? % "docs-literal") + (tm/capture! (:tmux/ref pane) {:from :history})))))) +;; => true +``` + +### Contextual targeting + +A linked window keeps the same physical window identity but has another +occurrence context. Effects use its preserved ref, so an unlinked occurrence +does not silently target a remaining link in another session. + + +```clojure +(require '[libtmux.core :as tm] + '[libtmux.data :as data]) + +(let [socket-path (java.nio.file.Path/of + (System/getProperty "libtmux.docs.socket") + (make-array String 0))] + (with-open [server (tm/open! {:socket-path socket-path})] + (let [first-session (tm/new-session! server {:name "docs-link-first"}) + second-session (tm/new-session! server {:name "docs-link-second"}) + window (first (data/windows first-session))] + (tm/link-window! (:tmux/ref window) (:tmux/ref second-session)) + (let [linked (first (filter #(= (:window/id window) (:window/id %)) + (data/windows + (tm/refresh! (:tmux/ref second-session)))))] + [(data/same-entity? window linked) (data/same-link? window linked)])))) +;; => [true false] +``` + +## Lifecycle, streaming and optional adapters + +`libtmux.async/runtime!` owns bounded JVM workers. Close it, or use `with-open`; +closing a borrowed server or executor is never an adapter side effect. Tasks +have explicit admission, cancellation and deadline results, and expose a Java +`CompletionStage` for Manifold or other JVM integration. + +Control observations have an explicit owner and terminal result. A bounded +consumer can lose events; an upstream gap and adapter loss remain distinct. +Do not model a subscription as an unbounded lazy sequence, and do not perform +blocking observation work in a core.async `go` block. Add the optional +`io.github.libtmux/libtmux-clojure-core-async` dependency only for +`libtmux.core-async`. + +An empty slow stream reports a timeout without becoming terminal. A later event +remains readable from the same owned stream. + + +```clojure +(require '[libtmux.control :as control] + '[libtmux.core :as tm]) +(import '[java.lang AutoCloseable] + '[java.time Duration]) + +(let [socket-path (java.nio.file.Path/of + (System/getProperty "libtmux.docs.socket") + (make-array String 0))] + (with-open [server (tm/open! {:socket-path socket-path + :binary (System/getProperty + "libtmux.docs.binary")}) + connection (control/attach! + (:tmux/ref (first (tm/sessions! server))) + (Duration/ofSeconds 2)) + stream (control/subscribe! connection)] + (let [initial (control/next! stream (Duration/ofMillis 25)) + pane (first (:reply/lines + (control/send! connection + ["new-window" "-P" "-F" "#{pane_id}" + (str "stty -echo; printf docs-slow;" + " read stop")]))) + event (control/next! stream (Duration/ofMillis 900))] + [(= :timeout (:stream/status initial)) + (= pane (:pane/id event))]))) +;; => [true true] +``` + + +```clojure +(require '[clojure.core.async :as async] + '[libtmux.control :as control] + '[libtmux.core :as tm] + '[libtmux.core-async :as adapter]) +(import '[java.time Duration]) + +(let [socket-path (java.nio.file.Path/of + (System/getProperty "libtmux.docs.socket") + (make-array String 0))] + (with-open [server (tm/open! {:socket-path socket-path + :binary (System/getProperty + "libtmux.docs.binary")}) + connection (control/attach! + (:tmux/ref (first (tm/sessions! server))) + (Duration/ofSeconds 2))] + (let [owner (adapter/observe! connection) + pane (first (:reply/lines + (control/send! connection + ["new-window" "-P" "-F" "#{pane_id}" + (str "stty -echo; printf docs-async;" + " read stop")]))) + timeout (async/timeout 900) + [event port] (async/alts!! [(adapter/events owner) timeout] + :priority true)] + (async/close! (adapter/events owner)) + [(and (identical? port (adapter/events owner)) (= pane (:pane/id event))) + (:reason (async/ [true :adapter-stopped] +``` + +### Manifold CompletionStage recipe + +Manifold remains a consumer dependency. Add +`manifold/manifold {:mvn/version "0.5.0"}` to the application, then adapt the +public `CompletionStage`; it does not become a `libtmux` dependency. `then!` +uses the runtime's bounded callback executor. A Manifold deferred observes the +stage; cancelling that deferred does not cancel the task, so call `cancel!` +explicitly when ownership requires it. + + +```clojure +(require '[libtmux.async :as async] + '[libtmux.core :as tm] + '[manifold.deferred :as deferred]) +(import '[java.util.concurrent CompletionStage]) + +(let [socket-path (java.nio.file.Path/of + (System/getProperty "libtmux.docs.socket") + (make-array String 0))] + (with-open [server (tm/open! {:socket-path socket-path}) + runtime (async/runtime! {})] + (tm/new-session! server {:name "docs-manifold"}) + (let [task (async/submit! runtime #(tm/snapshot! server)) + stage (async/then! runtime task + #(boolean (some (fn [session] + (= "docs-manifold" + (:session/name session))) + (:tmux/sessions %)))) + observed (deferred/->deferred stage)] + [(instance? CompletionStage stage) @observed]))) +;; => [true true] +``` + +## Compatibility and Java interop + +The façade supports JDK 21 or newer and the repository's tmux 3.2a through 3.7c +matrix. It is JVM-only: it does not run in ClojureScript, Babashka or a native +image. Existing Java handles, `FilterExpr`, the optional Jackson codec and Java +async types remain directly usable when the Clojure functions do not cover an +operation. + +[`clojure-reference.md`](clojure-reference.md) lists every captured key and +relation, its availability, identity meaning and Java filter support. diff --git a/gradle/clojure-runner/agent/libtmux/internal/loadguard/LoadGuard.java b/gradle/clojure-runner/agent/libtmux/internal/loadguard/LoadGuard.java new file mode 100644 index 00000000..192760e4 --- /dev/null +++ b/gradle/clojure-runner/agent/libtmux/internal/loadguard/LoadGuard.java @@ -0,0 +1,43 @@ +package libtmux.internal.loadguard; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Bootstrap-visible checkpoint used only by the namespace-load test agent. */ +public final class LoadGuard { + private static final List violations = new ArrayList<>(); + private static Map coverage = Map.of(); + private static boolean armed; + + private LoadGuard() {} + + public static synchronized void install(Map intercepted) { + coverage = Map.copyOf(intercepted); + } + + public static synchronized void begin() { + if (coverage.isEmpty() || armed) { + throw new IllegalStateException("namespace load guard is unavailable or already armed"); + } + violations.clear(); + armed = true; + } + + public static synchronized List end() { + armed = false; + return List.copyOf(violations); + } + + public static synchronized Map coverage() { + return coverage; + } + + public static synchronized void check(String kind, String site) { + if (armed) { + String violation = kind + ":" + site; + violations.add(violation); + throw new IllegalStateException("NAMESPACE_LOAD_EFFECT: " + violation); + } + } +} diff --git a/gradle/clojure-runner/agent/libtmux/internal/loadguard/NamespaceAgent.java b/gradle/clojure-runner/agent/libtmux/internal/loadguard/NamespaceAgent.java new file mode 100644 index 00000000..7e323c9d --- /dev/null +++ b/gradle/clojure-runner/agent/libtmux/internal/loadguard/NamespaceAgent.java @@ -0,0 +1,121 @@ +package libtmux.internal.loadguard; + +import java.lang.instrument.ClassFileTransformer; +import java.lang.instrument.Instrumentation; +import java.security.ProtectionDomain; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.jar.JarFile; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +/** Rejects resource creation while the native runner requires public namespaces. */ +public final class NamespaceAgent { + private static final String GUARD = "libtmux/internal/loadguard/LoadGuard"; + private static final Map TARGETS = Map.ofEntries( + Map.entry("java/util/concurrent/ThreadPoolExecutor", "executor"), + Map.entry("java/util/concurrent/ForkJoinPool", "executor"), + Map.entry("java/util/concurrent/ThreadPerTaskExecutor", "executor"), + Map.entry("java/lang/Thread", "thread"), + Map.entry("java/lang/VirtualThread", "thread"), + Map.entry("java/lang/ProcessBuilder", "process"), + Map.entry("java/net/Socket", "socket"), + Map.entry("java/net/ServerSocket", "socket"), + Map.entry("java/net/DatagramSocket", "socket"), + Map.entry("sun/nio/ch/SocketChannelImpl", "socket"), + Map.entry("sun/nio/ch/ServerSocketChannelImpl", "socket"), + Map.entry("sun/nio/ch/DatagramChannelImpl", "socket")); + + private NamespaceAgent() {} + + public static void premain(String bootstrapJars, Instrumentation instrumentation) throws Exception { + if (!instrumentation.isRetransformClassesSupported()) { + throw new IllegalStateException("namespace load guard requires class retransformation"); + } + if (bootstrapJars == null || bootstrapJars.isBlank()) { + throw new IllegalStateException("namespace load guard needs bootstrap helper and ASM JARs"); + } + var location = NamespaceAgent.class.getProtectionDomain().getCodeSource().getLocation().toURI(); + String[] jars = bootstrapJars.split(java.util.regex.Pattern.quote(java.io.File.pathSeparator)); + if (jars.length != 2 || jars[0].isBlank() || jars[1].isBlank()) { + throw new IllegalStateException("namespace load guard needs bootstrap helper and ASM JARs"); + } + for (String jar : jars) { + instrumentation.appendToBootstrapClassLoaderSearch(new JarFile(jar)); + } + Class guard = Class.forName(GUARD.replace('/', '.'), true, null); + instrumentation.redefineModule( + Object.class.getModule(), Set.of(guard.getModule()), Map.of(), Map.of(), Set.of(), Map.of()); + Map coverage = new HashMap<>(); + instrumentation.addTransformer(new ClassFileTransformer() { + @Override + public byte[] transform( + Module module, + ClassLoader loader, + String className, + Class redefining, + ProtectionDomain domain, + byte[] bytes) { + String kind = className == null ? null : TARGETS.get(className); + if (kind == null) { + return null; + } + ClassReader reader = new ClassReader(bytes); + ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS); + int[] count = {0}; + reader.accept(new ClassVisitor(Opcodes.ASM9, writer) { + @Override + public MethodVisitor visitMethod( + int access, String name, String descriptor, String signature, String[] exceptions) { + MethodVisitor original = super.visitMethod(access, name, descriptor, signature, exceptions); + boolean target = switch (kind) { + case "thread" -> name.equals("start"); + case "process" -> name.equals("start") || name.equals("startPipeline"); + default -> name.equals(""); + }; + if (!target || (access & (Opcodes.ACC_NATIVE | Opcodes.ACC_ABSTRACT)) != 0) { + return original; + } + count[0]++; + return new MethodVisitor(Opcodes.ASM9, original) { + @Override + public void visitCode() { + super.visitCode(); + visitLdcInsn(kind); + visitLdcInsn(className + "." + name + descriptor); + visitMethodInsn( + Opcodes.INVOKESTATIC, + GUARD, + "check", + "(Ljava/lang/String;Ljava/lang/String;)V", + false); + } + }; + } + }, 0); + byte[] transformed = writer.toByteArray(); + coverage.put(className, count[0]); + return transformed; + } + }, true); + for (String target : TARGETS.keySet()) { + Class type = Class.forName(target.replace('/', '.'), false, null); + if (!instrumentation.isModifiableClass(type)) { + throw new IllegalStateException("namespace load guard cannot transform " + target); + } + coverage.remove(target); + instrumentation.retransformClasses(type); + if (coverage.getOrDefault(target, 0) == 0) { + throw new IllegalStateException("namespace load guard intercepted no methods on " + target); + } + } + guard.getMethod("install", Map.class).invoke(null, coverage); + System.setProperty( + "libtmux.clojure.guard.agent", "-javaagent:" + new java.io.File(location) + "=" + bootstrapJars); + System.setProperty("libtmux.clojure.guard.classpath", new java.io.File(location).toString()); + } +} diff --git a/gradle/clojure-runner/agent/libtmux/internal/loadguard/ResourceProbe.java b/gradle/clojure-runner/agent/libtmux/internal/loadguard/ResourceProbe.java new file mode 100644 index 00000000..e1cad779 --- /dev/null +++ b/gradle/clojure-runner/agent/libtmux/internal/loadguard/ResourceProbe.java @@ -0,0 +1,49 @@ +package libtmux.internal.loadguard; + +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.Socket; +import java.net.StandardProtocolFamily; +import java.nio.channels.DatagramChannel; +import java.nio.channels.ServerSocketChannel; +import java.nio.channels.SocketChannel; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; + +/** Isolates JDK class initialization while checking each additional interception path. */ +public final class ResourceProbe { + private ResourceProbe() {} + + @SuppressWarnings("try") // Construction is the probe; close resources if interception fails. + public static void main(String[] arguments) throws Exception { + LoadGuard.begin(); + Throwable rejected = null; + try { + switch (arguments[0]) { + case "fork-join" -> { try (var resource = new ForkJoinPool(1)) {} } + case "virtual-executor" -> { try (var resource = Executors.newVirtualThreadPerTaskExecutor()) {} } + case "thread" -> { + var thread = new Thread(() -> {}); + thread.start(); + thread.join(); + } + case "virtual-thread" -> Thread.startVirtualThread(() -> {}).join(); + case "client-socket" -> { try (var resource = new Socket()) {} } + case "datagram" -> { try (var resource = new DatagramSocket(0, InetAddress.getLoopbackAddress())) {} } + case "nio-socket" -> { try (var resource = SocketChannel.open()) {} } + case "unix-server-socket" -> { + try (var resource = ServerSocketChannel.open(StandardProtocolFamily.UNIX)) {} + } + case "nio-datagram" -> { try (var resource = DatagramChannel.open()) {} } + default -> throw new IllegalArgumentException("unknown resource probe"); + } + } catch (Throwable failure) { + rejected = failure; + } + var effects = LoadGuard.end(); + if (effects.isEmpty()) { + throw new AssertionError("namespace guard did not reject " + arguments[0], rejected); + } + System.out.println("NAMESPACE_LOAD_EFFECTS " + effects); + } +} diff --git a/gradle/clojure-runner/libtmux/internal/guard_probe.clj b/gradle/clojure-runner/libtmux/internal/guard_probe.clj new file mode 100644 index 00000000..16e97a1a --- /dev/null +++ b/gradle/clojure-runner/libtmux/internal/guard_probe.clj @@ -0,0 +1,15 @@ +(ns libtmux.internal.guard-probe + (:import [java.net InetAddress ServerSocket] + [java.util.concurrent ExecutorService Executors TimeUnit])) + +(case (System/getProperty "libtmux.clojure.guard-probe") + "executor" (with-open [^ExecutorService executor (Executors/newSingleThreadExecutor)] nil) + "process" (let [^java.util.List argv [(str (System/getProperty "java.home") "/bin/java") "-version"] + process (.start (ProcessBuilder. argv))] + (try + (.readAllBytes (.getErrorStream process)) + (when-not (.waitFor process 900 TimeUnit/MILLISECONDS) + (throw (ex-info "guard probe child did not exit" {}))) + (finally (.destroyForcibly process)))) + "socket" (with-open [socket (ServerSocket. 0 0 (InetAddress/getLoopbackAddress))] nil) + nil) diff --git a/gradle/clojure-runner/libtmux/internal/test_runner.clj b/gradle/clojure-runner/libtmux/internal/test_runner.clj new file mode 100644 index 00000000..667b2e06 --- /dev/null +++ b/gradle/clojure-runner/libtmux/internal/test_runner.clj @@ -0,0 +1,126 @@ +(ns libtmux.internal.test-runner + (:require [clojure.java.io :as io] + [clojure.string :as str] + [clojure.test :as test]) + (:import [java.io File] + [java.lang.reflect Method] + [java.util.concurrent TimeUnit])) + +(defn- configured-namespaces [property] + (let [names (some-> (System/getProperty property) (str/split #","))] + (when-not (seq names) + (throw (ex-info (str "missing namespace list: " property) {:property property}))) + (mapv symbol names))) + +(defn- source-namespaces [roots] + (let [namespaces + (mapcat + (fn [root] + (let [^File directory (io/file root)] + (when-not (.isDirectory directory) + (throw (ex-info "Clojure test source root does not exist" {:root root}))) + (let [names (->> (file-seq directory) + (filter (fn [^File source] + (and (.isFile source) + (str/ends-with? (.getName source) "_test.clj")))) + (map (fn [^File source] + (-> (str (.relativize (.toPath directory) (.toPath source))) + (str/replace File/separator ".") + (str/replace "_" "-") + (str/replace #"\.clj$" "") + symbol))) + sort + vec)] + (when (empty? names) + (throw (ex-info "Clojure test source root contains no test namespaces" {:root root}))) + names))) + roots)] + (when-not (= (count namespaces) (count (set namespaces))) + (throw (ex-info "Clojure test source roots contain duplicate namespaces" + {:roots roots}))) + (vec namespaces))) + +(defn- require-without-reflection [namespaces] + (let [warnings (java.io.StringWriter.)] + (binding [*warn-on-reflection* true + *err* warnings] + (doseq [namespace namespaces] + (require namespace :reload))) + (when-not (str/blank? (str warnings)) + (throw (ex-info "Clojure namespace load emitted compiler warnings" + {:warnings (str warnings)}))))) + +(defn- run-tests! [namespaces] + (require-without-reflection namespaces) + (let [{:keys [test fail error]} (apply test/run-tests namespaces)] + (when (zero? test) + (throw (ex-info "Clojure runner discovered no tests" {:namespaces namespaces}))) + (when (pos? (+ fail error)) + (throw (ex-info "Clojure tests failed" {:fail fail :error error}))))) + +(defn- guarded-load! [namespaces] + (let [^Class guard (try (Class/forName "libtmux.internal.loadguard.LoadGuard") + (catch ClassNotFoundException cause + (throw (ex-info "Namespace loading requires the internal load-guard agent" {} cause)))) + ^Method begin (.getMethod guard "begin" (make-array Class 0)) + ^Method end (.getMethod guard "end" (make-array Class 0)) + ^Method coverage (.getMethod guard "coverage" (make-array Class 0))] + (.invoke begin nil (object-array 0)) + (let [failure (try (require-without-reflection namespaces) nil + (catch Throwable thrown thrown)) + effects (vec (.invoke end nil (object-array 0)))] + (when (seq effects) + (binding [*out* *err*] (println "NAMESPACE_LOAD_EFFECTS" (pr-str effects))) + (throw (ex-info "Namespace load created a forbidden resource" {:effects effects} failure))) + (when failure (throw failure)) + (println "Namespace load guard:" (into (sorted-map) (.invoke coverage nil (object-array 0))))))) + +(defn- guard-probes! [] + (let [agent (System/getProperty "libtmux.clojure.guard.agent")] + (when-not agent + (throw (ex-info "Namespace guard probes require the internal agent" {}))) + (let [property "libtmux.clojure.guard-probe" previous (System/getProperty property)] + (try + (doseq [probe ["executor" "process" "socket"]] + (System/setProperty property probe) + (let [failure (try (guarded-load! '[libtmux.internal.guard-probe]) nil + (catch clojure.lang.ExceptionInfo thrown thrown))] + (when-not (seq (:effects (ex-data failure))) + (throw (ex-info "Namespace guard did not reject the injected resource" {:probe probe} failure))) + (println "Namespace guard rejected:" probe))) + (finally + (if previous (System/setProperty property previous) (System/clearProperty property))))) + (doseq [probe ["fork-join" "virtual-executor" "thread" "virtual-thread" + "client-socket" "datagram" "nio-socket" + "unix-server-socket" "nio-datagram"]] + (let [^java.util.List argv + [(str (System/getProperty "java.home") "/bin/java") + "-XX:TieredStopAtLevel=1" "-Xshare:off" agent + "-cp" (System/getProperty "libtmux.clojure.guard.classpath") + "libtmux.internal.loadguard.ResourceProbe" probe] + process (.start (.redirectErrorStream (ProcessBuilder. argv) true))] + (try + (when-not (.waitFor process 900 TimeUnit/MILLISECONDS) + (throw (ex-info "Namespace guard probe did not finish within its budget" {:probe probe}))) + (let [output (slurp (.getInputStream process))] + (when-not (and (zero? (.exitValue process)) + (str/includes? output "NAMESPACE_LOAD_EFFECTS")) + (throw (ex-info "Namespace guard did not reject the injected resource" + {:probe probe :output output}))) + (println "Namespace guard rejected:" probe + (first (filter #(str/starts-with? % "NAMESPACE_LOAD_EFFECTS") + (str/split-lines output))))) + (finally + (when (.isAlive process) + (.destroyForcibly process) + (.waitFor process 900 TimeUnit/MILLISECONDS)))))))) + +(defn -main [& [mode & roots]] + (case mode + "load" (guarded-load! + (configured-namespaces "libtmux.clojure.main-namespaces")) + "guard-probes" (guard-probes!) + "test" (run-tests! (if (seq roots) + (source-namespaces roots) + (configured-namespaces "libtmux.clojure.test-namespaces"))) + (throw (ex-info "expected runner mode load, guard-probes, or test" {:mode mode})))) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bd1c1a2d..4b67a884 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,9 @@ maven-publish-plugin = "0.37.0" palantir-format = "2.97.0" cyclonedx-plugin = "3.4.1" japicmp = "0.23.1" +clojure = "1.12.4" +core-async = "1.8.741" +manifold = "0.5.0" [libraries] jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" } @@ -34,6 +37,9 @@ errorprone-annotations = { module = "com.google.errorprone:error_prone_annotatio errorprone-core = { module = "com.google.errorprone:error_prone_core", version.ref = "errorprone" } nullaway = { module = "com.uber.nullaway:nullaway", version.ref = "nullaway" } japicmp = { module = "com.github.siom79.japicmp:japicmp", version.ref = "japicmp" } +clojure = { module = "org.clojure:clojure", version.ref = "clojure" } +core-async = { module = "org.clojure:core.async", version.ref = "core-async" } +manifold = { module = "manifold:manifold", version.ref = "manifold" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/integration-tests/build.gradle.kts b/integration-tests/build.gradle.kts index 20272e7f..a8d91b57 100644 --- a/integration-tests/build.gradle.kts +++ b/integration-tests/build.gradle.kts @@ -7,12 +7,101 @@ plugins { id("libtmux.tmux-matrix") } +val integrationClojureVersion = + providers.gradleProperty("libtmuxClojureVersion").getOrElse(libs.versions.clojure.get()) + +repositories { + maven { url = uri("https://repo.clojars.org") } +} + dependencies { testImplementation(project(":libtmux")) testImplementation(project(":libtmux-jackson")) testImplementation(project(":libtmux-junit5")) + testImplementation(project(":libtmux-clojure")) + testImplementation(project(":libtmux-clojure-core-async")) + testImplementation("org.clojure:clojure:$integrationClojureVersion") + testImplementation(libs.manifold) +} + +sourceSets.test { + resources.srcDir("src/test/clojure") + resources.srcDir(rootProject.file("gradle/clojure-runner")) + resources.exclude("agent/**") } +val clojureIntegrationTest = + tasks.register("clojureIntegrationTest") { + group = "verification" + description = "Runs the native Clojure real-tmux integration suite." + dependsOn(tasks.named("testClasses")) + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("clojure.main") + args("-m", "libtmux.internal.test-runner", "test") + val namespaces = + provider { + fileTree("src/test/clojure") { include("**/*_test.clj") } + .files + .sorted() + .joinToString(",") { source -> + source.relativeTo(file("src/test/clojure")) + .invariantSeparatorsPath + .removeSuffix(".clj") + .replace('_', '-') + .replace('/', '.') + } + } + doFirst { + systemProperty( + "libtmux.clojure.test-namespaces", + providers.gradleProperty("libtmuxClojureTestNamespaces").orNull ?: namespaces.get(), + ) + } + jvmArgs("-Dclojure.main.report=stderr") + javaLauncher.set(javaToolchains.launcherFor { + languageVersion.set( + JavaLanguageVersion.of( + providers.gradleProperty("libtmuxJavaVersion").getOrElse("21").toInt(), + ), + ) + }) + environment.remove("TMUX") + environment.remove("TMUX_PANE") + environment("TMUX_TMPDIR", "/tmp/libtmux-java-test") + systemProperty("libtmux.docs.root", rootProject.projectDir.path) + systemProperty( + "libtmux.tmux", + providers.gradleProperty("libtmuxTmuxBinary").getOrElse("tmux"), + ) + providers.gradleProperty("libtmuxTmuxExpected").orNull?.let { + systemProperty("libtmux.tmux.expected", it) + } + inputs.files( + rootProject.fileTree(rootProject.projectDir) { + include("README.md", "*/README.md", "docs/guide/*.md") + }, + ).withPropertyName("clojureDocumentation") + .withPathSensitivity(PathSensitivity.RELATIVE) + } + +val prepareClojureIntegrationClasspath = + tasks.register("prepareClojureIntegrationClasspath") { + group = "build setup" + description = "Writes the reusable native Clojure integration classpath." + dependsOn(tasks.named("testClasses")) + val destination = layout.buildDirectory.file("clojure/integration-classpath.txt") + inputs.files(sourceSets.test.map { it.runtimeClasspath }) + outputs.file(destination) + doLast { + destination.get().asFile.apply { + parentFile.mkdirs() + writeText(sourceSets.test.get().runtimeClasspath.asPath + "\n") + } + } + } + +tasks.named("check") { dependsOn(clojureIntegrationTest) } + // The locale lane needs a JVM whose platform encoding is not UTF-8, which is a process-wide choice // read before main runs. So it gets its own fork rather than a flag, and the ordinary run excludes // it: under UTF-8 every assertion in it would pass for the wrong reason. diff --git a/integration-tests/src/test/clojure/libtmux/async_integration_test.clj b/integration-tests/src/test/clojure/libtmux/async_integration_test.clj new file mode 100644 index 00000000..63605d30 --- /dev/null +++ b/integration-tests/src/test/clojure/libtmux/async_integration_test.clj @@ -0,0 +1,90 @@ +(ns libtmux.async-integration-test + (:require [clojure.test :refer [deftest is]] + [libtmux.async :as async] + [libtmux.core :as core] + [libtmux.internal.fixture :as fixture]) + (:import [io.github.libtmux Server WakeReason] + [java.time Duration] + [java.util UUID])) + +(deftest reserved-wait-does-not-head-block-its-release + (fixture/with-owned-server + (fn [{:keys [^Server server]}] + (with-open [^libtmux.async.AsyncRuntime runtime (async/runtime! {:max-running 2})] + (let [channel (.channel server (str "clj-async-" (UUID/randomUUID))) + waiting (async/submit! runtime {:kind :wait} + #(.awaitReservingCapacity channel + (Duration/ofMillis 800))) + release (async/submit! runtime #(.signal channel))] + (is (nil? (deref release 500 ::timeout))) + (is (= WakeReason/SIGNALLED (deref waiting 900 ::timeout)))))))) + + +(deftest saturated-waiting-groups-retain-runtime-and-transport-release-capacity + (fixture/with-owned-server + (fn [{:keys [^Server server]}] + (with-open [transport (io.github.libtmux.transport.ProcessTransport. 2) + shared (Server/using (.config server) transport)] + (doseq [group [core/batch! core/chain!]] + (with-open [^libtmux.async.AsyncRuntime runtime + (async/runtime! {:max-running 2 :queue-limit 1})] + (let [channel (str "group-release-" (UUID/randomUUID)) + started (str channel "-started") + waiting (async/submit! runtime {:kind :wait} + #(group shared [["wait-for" "-S" started] + ["wait-for" channel]] + {:kind :wait}))] + (try + (is (= :signalled (core/await-channel! server started (Duration/ofMillis 800)))) + (let [queued (async/submit! runtime {:kind :wait} + #(group shared [["display-message" "-p" "queued-result"]] + {:kind :wait})) + excess (async/submit! runtime {:kind :wait} + #(throw (AssertionError. "excess waiting work ran")))] + (is (= :queued (async/task-state queued))) + (is (= {:status :busy :kind :wait} excess)) + (let [release (async/submit! runtime #(core/signal-channel! shared channel))] + (is (not (map? release)) "ordinary release lost its reserved admission") + (when-not (map? release) + (is (nil? (deref release 800 ::timeout))) + (is (= [:complete :complete] + (mapv :operation/outcome (deref waiting 800 ::timeout)))) + (let [result (deref queued 800 ::timeout)] + (is (= [:complete] (mapv :operation/outcome result))) + (is (= [["queued-result"]] (mapv :operation/stdout result)))) + (is (= [:terminal :terminal :terminal] + (mapv async/task-state [waiting queued release])))))) + (finally + (core/signal-channel! server channel) + (async/cancel! waiting)))))))))) + +(deftest cancelled-async-groups-retain-positional-data-and-transport-cause + (fixture/with-owned-server + (fn [{:keys [^Server server]}] + (doseq [group [core/batch! core/chain!]] + (with-open [^libtmux.async.AsyncRuntime runtime (async/runtime! {:max-running 2})] + (let [id (str "async-group-cancel-" (UUID/randomUUID)) + ready (str id "-ready") + release (str id "-release") + waiting (async/submit! runtime {:kind :wait} + #(group server [["set-buffer" "-b" id "applied"] + ["wait-for" "-S" ready] + ["wait-for" release] + ["set-buffer" "-b" (str id "-after") "uncertain"]] + {:kind :wait}))] + (try + (is (= :signalled (core/await-channel! server ready (Duration/ofMillis 800)))) + (is (true? (async/cancel! waiting))) + (let [failure (try (deref waiting 800 ::timeout) (catch Exception e e))] + (is (= :cancelled (:tmux/error (ex-data failure)))) + (is (= :unknown (:tmux/dispatch (ex-data failure)))) + (is (= [:unknown :unknown :unknown :unknown] + (mapv :operation/outcome (:tmux/results (ex-data failure))))) + (is (and (instance? Throwable failure) + (some #(instance? io.github.libtmux.transport.TmuxTransportException %) + (take-while some? (iterate #(.getCause ^Throwable %) failure))))) + (is (= :terminal (async/task-state waiting)))) + (is (= "applied" (core/show-buffer! server id))) + (finally + (core/signal-channel! server release) + (async/cancel! waiting))))))))) diff --git a/integration-tests/src/test/clojure/libtmux/control_integration_test.clj b/integration-tests/src/test/clojure/libtmux/control_integration_test.clj new file mode 100644 index 00000000..dd8deb69 --- /dev/null +++ b/integration-tests/src/test/clojure/libtmux/control_integration_test.clj @@ -0,0 +1,257 @@ +(ns libtmux.control-integration-test + (:require [clojure.string :as string] + [clojure.test :refer [deftest is]] + [libtmux.control :as control] + [libtmux.core :as tmux] + [libtmux.internal.fixture :as fixture]) + (:import [java.lang AutoCloseable ProcessHandle] + [java.nio.file Files Path] + [java.time Duration] + [java.util.concurrent CountDownLatch TimeUnit])) + +(def ^:private ^Duration limit (Duration/ofMillis 900)) + +(defn- shell-quote [s] (str "'" (string/replace (str s) "'" "'\\''") "'")) + +(defn- pane-event! [stream pane] + (let [deadline (+ (System/nanoTime) (.toNanos limit))] + (loop [] + (let [event (control/next! stream (Duration/ofNanos (max 0 (- deadline (System/nanoTime))))) ] + (when-not (= :event (:stream/status event)) + (throw (ex-info "Expected pane output before terminal or timeout" event))) + (if (= pane (:pane/id event)) event (recur)))))) + +(deftest protocol-reply-precedes-a-deferred-completion-marker + (fixture/with-owned-server + (fn [{:keys [server socket binary]}] + (let [session (first (tmux/sessions! server)) + marker (.resolveSibling ^Path socket "completed") + command (str (shell-quote binary) " -S " (shell-quote socket))] + (try + (with-open [^AutoCloseable connection (control/attach! (:tmux/ref session))] + (let [reply (control/send! connection + ["run-shell" (str command " wait-for release; : > " + (shell-quote marker) "; " command " wait-for -S finished")])] + (is (= :complete (:reply/status reply))) + (is (= :unverified (:operation/completion reply))) + (is (not (Files/exists marker (make-array java.nio.file.LinkOption 0)))) + (tmux/raw! server ["wait-for" "-S" "release"]) + (tmux/raw! server ["wait-for" "finished"]) + (is (Files/exists marker (make-array java.nio.file.LinkOption 0))) + (is (= ["aligned"] (:reply/lines (control/send! connection ["display-message" "-p" "aligned"])))))) + (finally (Files/deleteIfExists marker))))))) + +(deftest stream-preserves-split-utf8-and-independent-loss + (fixture/with-owned-server + (fn [{:keys [server]}] + (with-open [^AutoCloseable connection (control/attach! (:tmux/ref (first (tmux/sessions! server)))) + ^AutoCloseable raw (control/subscribe! connection {:capacity 32}) + ^AutoCloseable text (control/subscribe! connection {:encoding :utf-8 :malformed :replace}) + ^AutoCloseable idle (control/subscribe! connection {:capacity 1 :max-bytes 1})] + (let [reply (control/send! connection + ["new-window" "-P" "-F" "#{pane_id}" + "stty -echo; printf 'READY'; read -r start; printf '\\303'; read -r more; printf '\\251\\377'; read -r finish"]) + pane (first (:reply/lines reply))] + (is (= "READY" (String. ^bytes (:output/bytes (pane-event! raw pane)) "UTF-8"))) + (is (= "READY" (:output/text (pane-event! text pane)))) + (control/send! connection ["send-keys" "-t" pane "Enter"]) + (is (= [195] (mapv #(bit-and 255 %) (:output/bytes (pane-event! raw pane))))) + (is (= "" (:output/text (pane-event! text pane)))) + (control/send! connection ["send-keys" "-t" pane "Enter"]) + (is (= [169 255] (mapv #(bit-and 255 %) (:output/bytes (pane-event! raw pane))))) + (let [event (pane-event! text pane)] + (is (= "é�" (:output/text event))) + (is (= 0 (get-in event [:loss :upstream/events]))) + (is (= pane (get-in event [:tmux/identity :tmux/id])))) + (control/close! idle) + (let [ended (:stream/terminal (control/next! idle limit))] + (is (= :closed (:reason ended))) + (is (pos? (get-in ended [:loss :upstream/events])))) + (is (= :complete (:reply/status (control/send! connection ["display-message" "-p" "alive"]))))))))) + +(deftest close-wakes-reader-and-preserves-borrowed-connection + (fixture/with-owned-server + (fn [{:keys [server]}] + (with-open [^AutoCloseable connection (control/attach! (:tmux/ref (first (tmux/sessions! server)))) + ^AutoCloseable stream (control/subscribe! connection)] + (let [entered (CountDownLatch. 1) + exited (CountDownLatch. 1) + outcome (atom nil) + worker (.start (Thread/ofVirtual) + ^Runnable (fn [] + (.countDown entered) + (try (reset! outcome (control/next! stream)) + (finally (.countDown exited)))))] + (is (.await entered 900 TimeUnit/MILLISECONDS)) + (control/close! stream) + (is (.await exited 900 TimeUnit/MILLISECONDS)) + (.join worker 900) + (is (not (.isAlive worker))) + (is (= :closed (get-in @outcome [:stream/terminal :reason]))) + (is (= (control/terminal stream) (:stream/terminal @outcome))) + (is (= :complete (:reply/status (control/send! connection ["display-message" "-p" "still-alive"]))))))))) + +(deftest stalled-subscription-enforces-event-and-byte-bounds + (fixture/with-owned-server + (fn [{:keys [server socket binary]}] + (with-open [^AutoCloseable connection (control/attach! (:tmux/ref (first (tmux/sessions! server)))) + ^AutoCloseable anchor (control/subscribe! connection {:capacity 16 :max-bytes 64}) + ^AutoCloseable stalled (control/subscribe! connection {:capacity 2 :max-bytes 4})] + (let [tmux-command (str (shell-quote binary) " -S " (shell-quote socket)) + script (str "stty -echo; printf aa; " tmux-command " wait-for one; " + "printf bbb; " tmux-command " wait-for two; " + "printf cccc; " tmux-command " wait-for three; " + "printf 12345; " tmux-command " wait-for four; printf z; read stop") + pane (first (:reply/lines + (control/send! connection ["new-window" "-P" "-F" "#{pane_id}" script])))] + ;; Reading the anchor after every release proves each push reached the + ;; control reader before the next one can be emitted. + (is (= "aa" (String. ^bytes (:output/bytes (pane-event! anchor pane)) "UTF-8"))) + (doseq [[gate expected] [["one" "bbb"] ["two" "cccc"] + ["three" "12345"] ["four" "z"]]] + (control/send! connection ["wait-for" "-S" gate]) + (is (= expected (String. ^bytes (:output/bytes (pane-event! anchor pane)) "UTF-8")))) + (let [retained (pane-event! stalled pane)] + ;; Adding the oversized payload first evicts `cccc`, then drops + ;; itself; only the final byte fits in the bounded buffer. + (is (= "z" (String. ^bytes (:output/bytes retained) "UTF-8"))) + (is (= 4 (get-in retained [:loss :upstream/events]))) + (is (= 14 (get-in retained [:loss :upstream/bytes]))) + (is (= :timeout (:stream/status + (control/next! stalled (Duration/ofMillis 40))))))))))) + +(deftest pane-death-does-not-end-session-output-stream + (fixture/with-owned-server + (fn [{:keys [server]}] + (with-open [^AutoCloseable connection (control/attach! (:tmux/ref (first (tmux/sessions! server)))) + ^AutoCloseable stream (control/subscribe! connection)] + (let [anchor (first (:reply/lines + (control/send! connection + ["new-window" "-P" "-F" "#{pane_id}" + "stty -echo; read -r start; printf alive; read -r stop"]))) + pane (first (:reply/lines + (control/send! connection + ["new-window" "-P" "-F" "#{pane_id}" + "stty -echo; printf gone; read -r stop"])))] + (is (= "gone" (String. ^bytes (:output/bytes (pane-event! stream pane)) "UTF-8"))) + ;; Keep the source pane alive until its output arrives. A later + ;; target lookup proves its removal before the anchor emits output. + (is (= :complete (:reply/status (control/send! connection ["kill-window" "-t" pane])))) + (is (= :failed (:reply/status (control/send! connection ["list-panes" "-t" pane])))) + (is (= :complete (:reply/status (control/send! connection ["send-keys" "-t" anchor "Enter"])))) + (is (= "alive" (String. ^bytes (:output/bytes (pane-event! stream anchor)) "UTF-8"))) + (is (= :complete (:reply/status + (control/send! connection ["display-message" "-p" "connection-alive"]))))))))) + +(deftest remote-server-loss-drains-buffer-and-preserves-terminal-cause + (fixture/with-owned-server + (fn [{:keys [server]}] + (with-open [^AutoCloseable connection (control/attach! (:tmux/ref (first (tmux/sessions! server)))) + ^AutoCloseable anchor (control/subscribe! connection) + ^AutoCloseable buffered (control/subscribe! connection)] + (let [pane (first (:reply/lines + (control/send! connection + ["new-window" "-P" "-F" "#{pane_id}" + "printf buffered; read stop"])))] + (is (= "buffered" (String. ^bytes (:output/bytes (pane-event! anchor pane)) "UTF-8"))) + ;; The anchor is an offer fence: the independent buffered subscriber + ;; has received the same event before the daemon is stopped. + (tmux/raw! server ["kill-server"]) + (is (= "buffered" (String. ^bytes (:output/bytes (pane-event! buffered pane)) "UTF-8"))) + (let [terminal (:stream/terminal (control/next! buffered limit))] + (is (#{:control-exit :transport-failure} (:reason terminal))) + (is (some? (:cause terminal))) + (is (= terminal (control/terminal buffered))))))))) + + +(deftest closing-control-reclaims-only-its-authenticated-client + (fixture/with-owned-server + (fn [{:keys [server socket]}] + (doseq [exceptional? [false true]] + (let [owned (atom nil) + expected (ex-info "leave connection scope" {})] + (try + (with-open [^AutoCloseable connection + (control/attach! (:tmux/ref (first (tmux/sessions! server))))] + (let [pid (Long/parseLong ^String (first (:reply/lines + (control/send! connection + ["display-message" "-p" "#{client_pid}"])))) + ^ProcessHandle process (.orElseThrow (ProcessHandle/of pid)) + info (.info process) + parent (.orElseThrow (.parent process)) + started (.orElseThrow (.startInstant info)) + argv (vec (.orElse (.arguments info) (make-array String 0)))] + (is (.isAlive process)) + (is (= (.pid (ProcessHandle/current)) (.pid ^ProcessHandle parent))) + (is (some #{"-C"} argv)) + (is (some #{(str socket)} argv)) + (reset! owned {:process process :started started :argv argv + :exit (.onExit process)}) + (when exceptional? (throw expected)))) + (catch clojure.lang.ExceptionInfo failure + (is (identical? expected failure)))) + (let [{:keys [^ProcessHandle process ^java.util.concurrent.CompletableFuture exit started]} @owned] + (is (some? started)) + (.get exit 900 TimeUnit/MILLISECONDS) + (is (not (.isAlive process))) + (is (seq (tmux/sessions! server))))))))) + +(deftest live-competing-readers-deliver-each-payload-once-and-share-termination + (fixture/with-owned-server + (fn [{:keys [server socket binary]}] + (with-open [^AutoCloseable connection (control/attach! (:tmux/ref (first (tmux/sessions! server)))) + ^AutoCloseable anchor (control/subscribe! connection) + ^AutoCloseable shared (control/subscribe! connection)] + (let [command (str (shell-quote binary) " -S " (shell-quote socket)) + script (str "printf aa; " command " wait-for chunk-two; " + "printf bb; " command " wait-for chunk-three; " + "printf cc; " command " wait-for chunk-four; " + "printf dd; read done") + pane (first (:reply/lines + (control/send! connection ["new-window" "-P" "-F" "#{pane_id}" script]))) + entered (CountDownLatch. 2) + start (CountDownLatch. 1) + results [(promise) (promise)] + workers (mapv (fn [result] + (Thread/startVirtualThread + ^Runnable + (fn [] + (.countDown entered) + (deliver result + (try + (.await start) + (loop [events []] + (let [event (control/next! shared)] + (if (= :terminal (:stream/status event)) + {:events events :terminal (:stream/terminal event)} + (recur (if (= pane (:pane/id event)) + (conj events event) events))))) + (catch Exception failure failure)))))) + results)] + (try + (is (.await entered 900 TimeUnit/MILLISECONDS)) + (.countDown start) + (is (= "aa" (String. ^bytes (:output/bytes (pane-event! anchor pane)) "UTF-8"))) + (doseq [[gate payload] [["chunk-two" "bb"] ["chunk-three" "cc"] ["chunk-four" "dd"]]] + (control/send! connection ["wait-for" "-S" gate]) + (is (= payload (String. ^bytes (:output/bytes (pane-event! anchor pane)) "UTF-8")))) + (tmux/raw! server ["kill-server"]) + (let [finished (mapv #(deref % 900 ::timeout) results) + events (sort-by :event/sequence (mapcat :events finished)) + sequences (mapv :event/sequence events)] + (is (every? map? finished)) + (is (= ["aa" "bb" "cc" "dd"] + (mapv #(String. ^bytes (:output/bytes %) "UTF-8") events))) + (is (= 4 (count (set sequences)))) + (doseq [reader finished] + (is (apply < 0 (map :event/sequence (:events reader))))) + (is (= (:terminal (first finished)) (:terminal (second finished)))) + (is (= (control/terminal shared) (:terminal (first finished)))) + (is (#{:control-exit :transport-failure} (:reason (:terminal (first finished)))))) + (finally + (.countDown start) + (control/close! shared) + (doseq [^Thread worker workers] + (.interrupt worker) + (.join worker 900) + (is (not (.isAlive worker))))))))))) diff --git a/integration-tests/src/test/clojure/libtmux/core_async_integration_test.clj b/integration-tests/src/test/clojure/libtmux/core_async_integration_test.clj new file mode 100644 index 00000000..f5171338 --- /dev/null +++ b/integration-tests/src/test/clojure/libtmux/core_async_integration_test.clj @@ -0,0 +1,47 @@ +(ns libtmux.core-async-integration-test + (:require [clojure.core.async :as async] + [clojure.test :refer [deftest is]] + [libtmux.control :as control] + [libtmux.core :as tmux] + [libtmux.core-async :as adapter] + [libtmux.internal.fixture :as fixture]) + (:import [java.lang AutoCloseable] + [java.time Duration])) + +(defn- take-within [channel] + (let [timeout (async/timeout 900) + [value port] (async/alts!! [channel timeout] :priority true)] + (if (identical? port timeout) ::timeout value))) + +(defn- pane-event! [stream pane] + (loop [] + (let [event (control/next! stream (Duration/ofMillis 900))] + (if (= pane (:pane/id event)) event (recur))))) + +(deftest raw-bytes-consumer-close-and-borrowed-connection-lifecycle + (fixture/with-owned-server + (fn [{:keys [server]}] + (with-open [^AutoCloseable connection + (control/attach! (:tmux/ref (first (tmux/sessions! server)))) + ^AutoCloseable anchor (control/subscribe! connection)] + (let [owner (adapter/observe! connection) + pane (first (:reply/lines + (control/send! connection + ["new-window" "-P" "-F" "#{pane_id}" + "stty -echo; printf raw-bytes; read more; printf blocked; read stop"]))) + event (take-within (adapter/events owner))] + (is (= pane (:pane/id event))) + (is (= (mapv int (.getBytes "raw-bytes" "UTF-8")) + (mapv #(bit-and 255 %) (:output/bytes event)))) + (is (= (mapv int (.getBytes "raw-bytes" "UTF-8")) + (mapv #(bit-and 255 %) (:output/bytes (pane-event! anchor pane))))) + (control/send! connection ["send-keys" "-t" pane "Enter"]) + ;; The independent anchor proves the second push reached every Java + ;; subscription before the adapter consumer closes its channel. + (is (= "blocked" + (String. ^bytes (:output/bytes (pane-event! anchor pane)) "UTF-8"))) + (async/close! (adapter/events owner)) + (is (= :adapter-stopped (:reason (take-within (adapter/terminal owner))))) + (is (= :complete + (:reply/status + (control/send! connection ["display-message" "-p" "borrowed-alive"]))))))))) diff --git a/integration-tests/src/test/clojure/libtmux/core_integration_test.clj b/integration-tests/src/test/clojure/libtmux/core_integration_test.clj new file mode 100644 index 00000000..0f137655 --- /dev/null +++ b/integration-tests/src/test/clojure/libtmux/core_integration_test.clj @@ -0,0 +1,258 @@ +(ns libtmux.core-integration-test + (:require [clojure.test :refer [deftest is]] + [libtmux.core :as tmux] + [libtmux.data :as data] + [libtmux.internal.fixture :as fixture])) + +(deftest typed-effects-preserve-captured-data-and-explicit-reference + (fixture/with-owned-server + (fn [{:keys [server]}] + (let [session (tmux/new-session! server {:name "effects" :window-name "first"}) + window (first (data/windows session)) + pane (first (data/panes window)) + renamed (tmux/rename! (:tmux/ref session) "renamed")] + (is (thrown? clojure.lang.ExceptionInfo (tmux/rename! (assoc session :session/name "edited") "rejected"))) + (is (= "effects" (:session/name session))) + (is (= "renamed" (:session/name renamed))) + (is (nil? (tmux/find-session! server "missing"))) + (is (= "renamed" (:session/name (tmux/refresh! (:tmux/ref session))))) + (tmux/set-option! (:tmux/ref session) "@clj" "literal #{pane_id}") + (is (= "literal #{pane_id}" (get (tmux/options! (:tmux/ref session)) "@clj"))) + (tmux/unset-option! (:tmux/ref session) "@clj") + (is (nil? (get (tmux/options! (:tmux/ref session)) "@clj"))) + (tmux/set-environment! (:tmux/ref session) "CLJ_TEST" "literal") + (is (= "literal" (get-in (tmux/environment! (:tmux/ref session)) [:values "CLJ_TEST"]))) + (tmux/remove-environment! (:tmux/ref session) "CLJ_TEST") + (is (contains? (:removed (tmux/environment! (:tmux/ref session))) "CLJ_TEST")) + (tmux/unset-environment! (:tmux/ref session) "CLJ_TEST") + (is (not (contains? (:removed (tmux/environment! (:tmux/ref session))) "CLJ_TEST"))) + (tmux/set-hook! (:tmux/ref session) "after-new-window" ["display-message" "first"]) + (tmux/append-hook! (:tmux/ref session) "after-new-window" ["display-message" "second"]) + (is (= 2 (count (get (tmux/hooks! (:tmux/ref session)) "after-new-window")))) + (tmux/unset-hook! (:tmux/ref session) "after-new-window") + (tmux/set-buffer! server "clj" "literal\ntext") + (is (= "literal\ntext" (tmux/show-buffer! server "clj"))) + (is (some #(= "clj" (:buffer/name %)) (tmux/buffers! server))) + (if (.atLeast (.version ^io.github.libtmux.Server server) + (io.github.libtmux.TmuxVersion. 3 4 "")) + (tmux/delete-buffer! server "clj") + (let [error (try (tmux/delete-buffer! server "clj") + (catch clojure.lang.ExceptionInfo e e))] + (is (= :tmux/unsupported-version (:tmux/error (ex-data error)))) + (is (= :not-dispatched (:tmux/dispatch (ex-data error)))))) + (is (= (:pane/id pane) (tmux/expand! (:tmux/ref pane) "#{pane_id}"))) + (is (vector? (tmux/capture! (:tmux/ref pane) {:from :history}))) + (let [created (tmux/new-window! (:tmux/ref session) {:name "second" :detached? true}) + split (tmux/split-pane! (:tmux/ref (first (data/panes created))) + {:direction :right :percent 30})] + (is (= "second" (:window/name created))) + (is (string? (:pane/id split))) + (tmux/select! (:tmux/ref split)) + (is (:pane/active? (tmux/refresh! (:tmux/ref split)))) + (tmux/resize! (:tmux/ref split) 20 10) + (tmux/kill! (:tmux/ref split)) + (is (nil? (tmux/refresh! (:tmux/ref split))))) + (tmux/kill! (:tmux/ref renamed)) + (is (nil? (tmux/find-session! server "renamed"))))))) + +(deftest contextual-refresh-does-not-escape-unlinked-session + (fixture/with-owned-server + (fn [{:keys [server]}] + (let [first-session (tmux/new-session! server {:name "link-first"}) + second-session (tmux/new-session! server {:name "link-second"}) + window (first (data/windows first-session))] + (tmux/link-window! (:tmux/ref window) (:tmux/ref second-session)) + (let [linked (first (filter #(= (:window/id window) (:window/id %)) + (data/windows (tmux/refresh! (:tmux/ref second-session))))) + pane (first (data/panes linked))] + (is (data/same-entity? (first (data/panes window)) pane)) + (is (not (data/same-link? (first (data/panes window)) pane))) + (tmux/unlink-window! (:tmux/ref linked)) + (is (nil? (tmux/refresh! (:tmux/ref linked)))) + (is (nil? (tmux/refresh! (:tmux/ref pane)))) + (is (some? (tmux/refresh! (:tmux/ref window))))))))) + +(deftest closing-resources-does-not-kill-borrowed-server + (fixture/with-owned-server + (fn [{:keys [server socket binary]}] + (let [owned (tmux/open! {:socket-path socket :binary binary}) + captured (tmux/snapshot! owned)] + (tmux/close! owned) + (is (seq (:tmux/sessions captured))) + (is (seq (tmux/sessions! server))) + (let [error (try (tmux/snapshot! owned) (catch clojure.lang.ExceptionInfo e e))] + (is (= :tmux/closed (:tmux/error (ex-data error))))))))) + +(deftest mutation-followed-by-capture-failure-is-not-safe-to-retry + (fixture/with-owned-server + (fn [{:keys [server]}] + (let [cause (ex-info "capture failed" {:tmux/error :tmux/transport :tmux/dispatch :not-dispatched}) + error (with-redefs [data/from-capture (fn [_] (throw cause))] + (try (tmux/new-session! server {:name "mutation-applied"}) + (catch clojure.lang.ExceptionInfo e e)))] + (is (identical? cause (.getCause ^Throwable error))) + (is (= :unknown (:tmux/dispatch (ex-data error)))) + (is (= :possible (:tmux/effects (ex-data error)))) + (is (some? (tmux/find-session! server "mutation-applied"))))))) + +(deftest refresh-fences-reindex-and-replacement-slots + (fixture/with-owned-server + (fn [{:keys [server]}] + (let [session (tmux/new-session! server {:name "reindex"}) + original (first (data/windows session)) + original-pane (first (data/panes original))] + (tmux/move-window! (:tmux/ref original) (:tmux/ref session) 7) + (is (nil? (tmux/refresh! (:tmux/ref original)))) + (is (nil? (tmux/refresh! (:tmux/ref original-pane)))) + (let [moved (first (data/windows (tmux/refresh! (:tmux/ref session)))) + replacement (tmux/new-window! (:tmux/ref session) + {:name "replacement" :index (:window/index original)}) + error (try (tmux/select! (:tmux/ref original)) + (catch clojure.lang.ExceptionInfo e e))] + (is (data/same-entity? original moved)) + (is (not (data/same-link? original moved))) + (is (not (data/same-entity? original replacement))) + (is (nil? (tmux/refresh! (:tmux/ref original)))) + (is (= :tmux/not-found (:tmux/error (ex-data error)))) + (is (:window/active? (tmux/refresh! (:tmux/ref replacement))))))))) + +(deftest split-retains-the-callers-linked-window-context + (fixture/with-owned-server + (fn [{:keys [server]}] + (let [session (tmux/new-session! server {:name "split-links"}) + window (first (data/windows session)) + target (str (:session/id session) ":9")] + (is (zero? (:exit-code (tmux/raw! server ["link-window" "-s" (:window/id window) "-t" target])))) + (let [linked (first (filter #(= 9 (:window/index %)) (tmux/windows! server))) + pane (first (data/panes linked)) + created (tmux/split-pane! (:tmux/ref pane) {:direction :right})] + (is (= (:tmux/link pane) (:tmux/link created))) + (is (not= (:pane/id pane) (:pane/id created))) + (tmux/unlink-window! (:tmux/ref linked)) + (is (nil? (tmux/refresh! (:tmux/ref pane)))) + (is (nil? (tmux/refresh! (:tmux/ref created)))) + (is (zero? (:exit-code (tmux/raw! server ["link-window" "-s" (:window/id window) "-t" target])))) + (let [restored (tmux/refresh! (:tmux/ref created))] + (is (data/same-link? created restored)))))))) + +(deftest two-handle-effects-reject-cross-endpoint-references + (fixture/with-owned-server + (fn [{left :server}] + (fixture/with-owned-server + (fn [{right :server}] + (let [left-session (first (tmux/sessions! left)) + left-window (first (data/windows left-session)) + left-pane (first (data/panes left-window)) + right-session (first (tmux/sessions! right)) + right-window (first (data/windows right-session))] + (doseq [operation [#(tmux/link-window! (:tmux/ref left-window) (:tmux/ref right-session)) + #(tmux/move-window! (:tmux/ref left-window) (:tmux/ref right-session) 9) + #(tmux/join-pane! (:tmux/ref left-pane) (:tmux/ref right-window))]] + (let [error (try (operation) (catch clojure.lang.ExceptionInfo e e))] + (is (= :tmux/validation (:tmux/error (ex-data error)))) + (is (= :not-dispatched (:tmux/dispatch (ex-data error)))) + (is (instance? IllegalArgumentException (.getCause ^Throwable error))))) + (is (= 1 (count (tmux/windows! left)))) + (is (= 1 (count (tmux/windows! right)))))))))) + +(deftest singleton-command-is-a-literal-executable-with-no-arguments + (fixture/with-owned-server + (fn [{:keys [server socket binary]}] + (let [script (.resolve (.getParent ^java.nio.file.Path socket) "literal executable;name")] + (java.nio.file.Files/writeString script "#!/bin/sh\n[ \"$#\" -eq 0 ] || exit 1\n\"$CLJ_TMUX\" -S \"$CLJ_SOCKET\" wait-for -S singleton-started\nexec /bin/cat\n" + (make-array java.nio.file.OpenOption 0)) + (java.nio.file.Files/setPosixFilePermissions + script (java.nio.file.attribute.PosixFilePermissions/fromString "rwx------")) + (try + (let [session (tmux/new-session! server {:name "literal-executable" :command [(str script)] + :environment {"CLJ_TMUX" binary "CLJ_SOCKET" (str socket)}})] + (is (= :signalled (tmux/await-channel! server "singleton-started" + (java.time.Duration/ofMillis 500)))) + (is (= "literal-executable" (:session/name session))) + (is (= 1 (count (data/panes session))))) + (finally (java.nio.file.Files/deleteIfExists script))))))) + +(deftest command-groups-attribute-reads-partial-failure-and-dependent-layout + (fixture/with-owned-server + (fn [{:keys [server]}] + (let [session (first (tmux/sessions! server)) + pane (first (data/panes session)) + reads (mapv (fn [index] ["display-message" "-p" "-t" (:pane/id pane) + (str index ":#{pane_id}")]) (range 4))] + (doseq [run [tmux/batch! tmux/chain!]] + (let [results (run server reads)] + (is (= [:complete :complete :complete :complete] (mapv :operation/outcome results))) + (is (= (mapv #(vector (str % ":" (:pane/id pane))) (range 4)) + (mapv :operation/stdout results)))) + (let [results (run server [["set-buffer" "-b" "group-before" "applied"] + ["select-pane" "-t" "%999999999"] + ["set-buffer" "-b" "group-after" "skipped"]])] + (is (= [:complete :failed :skipped] (mapv :operation/outcome results))) + (is (= "applied" (tmux/show-buffer! server "group-before"))) + (is (not-any? #(= "group-after" (:buffer/name %)) (tmux/buffers! server))))) + (let [results (tmux/chain! server [["new-window" "-t" (:session/id session) "-n" "group-layout"] + ["split-window" "-h"] + ["select-layout" "even-horizontal"] + ["display-message" "-p" "#{window_name}:#{window_panes}"]])] + (is (= [:complete :complete :complete :complete] (mapv :operation/outcome results))) + (is (= ["group-layout:2"] (:operation/stdout (last results))))))))) + +(deftest waiting-groups-reserve-release-capacity + (fixture/with-owned-server + (fn [{:keys [server]}] + (with-open [transport (io.github.libtmux.transport.ProcessTransport. 2) + shared (io.github.libtmux.Server/using (.config ^io.github.libtmux.Server server) transport)] + (doseq [run [tmux/batch! tmux/chain!]] + (let [result (promise) + worker (Thread/startVirtualThread + ^Runnable (fn [] + (deliver result + (try (run shared [["wait-for" "-S" "group-started"] + ["wait-for" "group-release"]] + {:kind :wait}) + (catch Exception e e)))))] + (try + (is (= :signalled (tmux/await-channel! server "group-started" (java.time.Duration/ofMillis 800)))) + (let [refused (try (run shared [["wait-for" "second-wait"]] {:kind :wait}) + (catch clojure.lang.ExceptionInfo e e))] + (is (= :not-dispatched (:tmux/dispatch (ex-data refused)))) + (is (= [:skipped] (mapv :operation/outcome (:tmux/results (ex-data refused)))))) + (tmux/signal-channel! shared "group-release") + (let [finished (deref result 800 ::timeout)] + (is (vector? finished)) + (is (= [:complete :complete] (mapv :operation/outcome finished)))) + (finally + (.interrupt worker) + (.join worker 800) + (is (not (.isAlive worker))))))))))) + +(deftest cancelled-groups-report-unknown-positions-and-preserve-partial-effects + (fixture/with-owned-server + (fn [{:keys [server]}] + (let [result (promise) + worker (Thread/startVirtualThread + ^Runnable (fn [] + (deliver result + (try (tmux/batch! server + [["set-buffer" "-b" "cancel-before" "applied"] + ["wait-for" "-S" "cancel-started"] + ["wait-for" "cancel-blocked"] + ["set-buffer" "-b" "cancel-after" "uncertain"]] + {:kind :wait}) + (catch Exception e e)))))] + (try + (is (= :signalled (tmux/await-channel! server "cancel-started" (java.time.Duration/ofMillis 800)))) + (.interrupt worker) + (let [error (deref result 800 ::timeout)] + (is (instance? clojure.lang.ExceptionInfo error)) + (is (= :unknown (:tmux/dispatch (ex-data error)))) + (is (= [:unknown :unknown :unknown :unknown] + (mapv :operation/outcome (:tmux/results (ex-data error))))) + (is (instance? io.github.libtmux.transport.TmuxTransportException + (.getCause ^Throwable error)))) + (is (= "applied" (tmux/show-buffer! server "cancel-before"))) + (finally + (tmux/signal-channel! server "cancel-blocked") + (.interrupt worker) + (.join worker 800) + (is (not (.isAlive worker))))))))) diff --git a/integration-tests/src/test/clojure/libtmux/data_integration_test.clj b/integration-tests/src/test/clojure/libtmux/data_integration_test.clj new file mode 100644 index 00000000..de7a77dc --- /dev/null +++ b/integration-tests/src/test/clojure/libtmux/data_integration_test.clj @@ -0,0 +1,113 @@ +(ns libtmux.data-integration-test + (:require [clojure.edn :as edn] + [clojure.test :refer [deftest is]] + [libtmux.data :as data] + [libtmux.internal.fixture :as fixture]) + (:import [io.github.libtmux Pane_ Server ServerConfig ServerEndpoint SessionId Session_ TmuxVersion Window_] + [io.github.libtmux.control ControlClient] + [io.github.libtmux.query FilterExpr] + [io.github.libtmux.transport ProcessTransport TmuxTransport] + [java.nio.file Path] + [java.time Duration] + [java.util.regex Pattern])) + +(set! *warn-on-reflection* true) + +(defn- command! [^Server server & argv] + (is (.succeeded (.cmd server ^java.util.List (vec argv))))) + +(deftest capture-values-preserve-occurrences-and-never-read-live-state + (fixture/with-owned-server + (fn [{:keys [socket binary]}] + (let [config (-> (ServerConfig/builder) + (.binary binary) + (.endpoint (ServerEndpoint/socketPath + ^Path socket)) + (.defaultTimeout (Duration/ofMillis 900)) + .build) + calls (atom 0) + old (atom nil)] + (with-open [server (Server/open config) + processes (ProcessTransport.)] + (command! server "rename-session" "-t" "$0" "alpha") + (command! server "new-session" "-d" "-s" "beta") + (command! server "link-window" "-s" "alpha:0" "-t" "alpha:9") + (command! server "link-window" "-s" "alpha:0" "-t" "beta:9") + (command! server "select-window" "-t" "alpha:9") + (with-open [client (ControlClient/attach config (SessionId. "$0")) + measured (Server/using + config + (reify TmuxTransport + (execute [_ request] + (swap! calls inc) + (.execute processes request)) + (close [_])))] + (let [captured (.capture measured) + entries (data/from-capture captured) + acquired @calls + alpha (data/exactly-one + (filter #(= "alpha" (:session/name %)) (:tmux/sessions entries))) + links (data/windows alpha) + panes (data/panes alpha) + shared (filter #(= (-> links first :window/id) (:window/id %)) + (:tmux/windows entries)) + detached (data/data entries)] + (is (= 2 acquired)) + (is (= 3 (count shared))) + (is (= 2 (count links))) + (is (= 2 (count panes))) + (is (data/same-entity? (first panes) (second panes))) + (is (not (data/same-link? (first panes) (second panes)))) + (is (= 9 (:window/index (data/active-window alpha)))) + (is (= 9 (get-in (data/active-pane alpha) [:tmux/link :window/index]))) + (let [matches (data/predicate (.is (Session_/name) "alpha"))] + (is (matches (assoc alpha :session/name "edited"))) + (is (thrown? clojure.lang.ExceptionInfo (matches (data/data alpha))))) + (let [active (.is (Window_/active) true) + index-zero (.is (Window_/index) 0) + same-child (.any (Session_/windows) (.and ^FilterExpr active index-zero)) + separate-children (.and ^FilterExpr (.any (Session_/windows) active) + (.any (Session_/windows) index-zero))] + (is (false? ((data/predicate same-child) alpha))) + (is (true? ((data/predicate separate-children) alpha)))) + (doseq [[items expressions] + [[(:tmux/sessions entries) + [(FilterExpr/and []) (FilterExpr/or []) + (.is (Session_/attached) false) + (.matches (Session_/name) (Pattern/compile "LPH" Pattern/CASE_INSENSITIVE)) + (.all (Session_/windows) (FilterExpr/and [])) + (.none (Session_/windows) (FilterExpr/or []))]] + [(:tmux/windows entries) + [(.is (Window_/session) (.is (Session_/name) "alpha")) + (.any (Window_/panes) (.is (Pane_/active) true))]] + [(:tmux/panes entries) + [(.is (Pane_/active) false) (.greaterThan (Pane_/width) 0)]]] + ^FilterExpr expression expressions] + (let [duplicates (into (vec items) (take 1 items)) + direct (filterv #(.test expression %) (mapv :tmux/ref duplicates)) + lazy-result (mapv :tmux/ref (data/matching expression duplicates)) + reduced-result (into [] (comp (data/matching expression) (map :tmux/ref)) duplicates)] + (is (= direct lazy-result reduced-result)) + (is (every? true? (map identical? direct reduced-result))))) + (is (thrown? IllegalArgumentException (.is (Session_/name) nil))) + (is (every? (if (.atLeast (TmuxVersion/parse (:tmux/version entries)) + (TmuxVersion. 3 7 "")) + false? nil?) + (map :pane/floating? (:tmux/panes entries)))) + (is (seq (:tmux/clients entries))) + (is (= "$0" (get-in entries [:tmux/clients 0 :client/session :session/id]))) + (is (= detached (edn/read-string (pr-str detached)))) + (dotimes [_ 20] + (hash entries) + (pr-str entries) + (= entries entries) + (into [] (comp (filter :pane/active?) (map :pane/id)) (data/panes alpha)) + (group-by :window/id (:tmux/windows entries))) + (command! server "rename-window" "-t" "alpha:0" "new-name") + (is (not= "new-name" (:window/name (first links)))) + (is (= detached (data/data entries))) + (is (= acquired @calls)) + (reset! old entries))) + (is (= (data/data @old) (edn/read-string (pr-str (data/data @old))))) + (is (thrown? IllegalStateException + (.capture ^Server (:tmux/ref @old))))))))) diff --git a/integration-tests/src/test/clojure/libtmux/documentation_examples_test.clj b/integration-tests/src/test/clojure/libtmux/documentation_examples_test.clj new file mode 100644 index 00000000..5066148a --- /dev/null +++ b/integration-tests/src/test/clojure/libtmux/documentation_examples_test.clj @@ -0,0 +1,207 @@ +(ns libtmux.documentation-examples-test + (:require [clojure.edn :as edn] + [clojure.string :as str] + [clojure.test :refer [deftest is]] + [libtmux.control :as control] + [libtmux.core :as tmux] + [libtmux.internal.fixture :as fixture]) + (:import [java.lang AutoCloseable] + [java.nio.file Files LinkOption Path] + [java.util UUID])) + +(def ^:private fence + #"(?ms)(?:\s*\n)?^```clojure\s*\n(.*?)^```") +(def ^:private output-line #"(?m)^\s*;;\s*=>\s*(.+?)\s*$") +(def ^:private required-documents ["libtmux-clojure/README.md" + "docs/guide/clojure.md" + "docs/guide/clojure-reference.md"]) +(def ^:private socket-property "libtmux.docs.socket") +(def ^:private binary-property "libtmux.docs.binary") +(def ^:private fields-marker #"(?s)") + +(defn- document-root [] + (Path/of (System/getProperty "libtmux.docs.root") (make-array String 0))) + +(defn- regular-file? [^Path path] + (Files/isRegularFile path (make-array LinkOption 0))) + +(defn- listed [^Path directory] + (with-open [entries (Files/list directory)] + (vec (.toList entries)))) + +(defn- require-documents! [^Path root] + (doseq [relative required-documents + :let [^String relative relative + ^Path document (.resolve root relative)]] + (when-not (regular-file? document) + (throw (ex-info "required Clojure documentation is missing" + {:docs/error :missing-document :document relative})))) + root) + +(defn- readable-documents [^Path root] + (require-documents! root) + (let [readmes (->> (listed root) + (map (fn [^Path directory] (.resolve directory "README.md"))) + (filter regular-file?)) + guides (->> (listed (.resolve root "docs/guide")) + (filter #(str/ends-with? (str (.getFileName ^Path %)) ".md")))] + (->> (concat [(.resolve root "README.md")] + readmes + guides) + (filter regular-file?) + (sort-by str) + vec))) + +(defn- line-number [text offset] + (inc (count (filter #(= \newline %) (subs text 0 offset))))) + +(defn- snippet! [source line directive code] + (when-not (= "fixture" directive) + (throw (ex-info "Clojure documentation snippets require the fixture directive" + {:docs/error :missing-or-unknown-directive + :document source :line line :directive directive}))) + (when-not (re-find #"\(with-open\s+\[server\b" code) + (throw (ex-info "Clojure documentation snippets must close their Server" + {:docs/error :missing-ownership :document source :line line}))) + (let [outputs (re-seq output-line code)] + (when-not (= 1 (count outputs)) + (throw (ex-info "Clojure documentation snippets require one displayed EDN result" + {:docs/error :missing-or-ambiguous-output + :document source :line line :outputs (count outputs)}))) + (let [[_ expected] (first outputs)] + {:document source + :line line + :code (str/replace code output-line "") + :expected (edn/read-string expected)}))) + +(defn- snippets-in [^Path root ^Path document] + (let [text (slurp (str document)) + matcher (re-matcher fence text) + source (str (.relativize root document))] + (loop [snippets []] + (if (.find matcher) + (recur (conj snippets + (snippet! source + (line-number text (.start matcher)) + (.group matcher 1) + (.group matcher 2)))) + snippets)))) + +(defn- documented-snippets [^Path root] + (->> (readable-documents root) + (mapcat #(snippets-in root %)) + vec)) + +(defn- documented-fields! [^Path root] + (let [reference (.resolve root "docs/guide/clojure-reference.md") + markers (re-seq fields-marker (slurp (str reference)))] + (when-not (= 1 (count markers)) + (throw (ex-info "Clojure field reference needs one field marker" + {:docs/error :missing-or-ambiguous-field-marker + :count (count markers)}))) + (let [fields (edn/read-string (second (first markers)))] + (when-not (and (vector? fields) + (every? #(and (keyword? %) (namespace %)) fields)) + (throw (ex-info "Clojure field marker must contain qualified keywords" + {:docs/error :invalid-field-marker}))) + (set fields)))) + +(defn- captured-fields [value] + (cond + (map? value) (into (set (filter #(and (keyword? %) (namespace %)) (keys value))) + (mapcat captured-fields (vals value))) + (sequential? value) (into #{} (mapcat captured-fields value)) + :else #{})) + +(defn- projected-fields! [] + (fixture/with-owned-server + (fn [{:keys [server]}] + (let [session (first (tmux/sessions! server))] + (with-open [^AutoCloseable connection (control/attach! (:tmux/ref session))] + (captured-fields (tmux/snapshot! server))))))) + +(defn- restore-property! [property value] + (if value + (System/setProperty property value) + (System/clearProperty property))) + +(defn- with-fixture-properties [^Path socket f] + (let [socket-before (System/getProperty socket-property) + binary-before (System/getProperty binary-property)] + (try + (System/setProperty socket-property (str socket)) + (System/setProperty binary-property (System/getProperty "libtmux.tmux")) + (f) + (finally + (restore-property! socket-property socket-before) + (restore-property! binary-property binary-before))))) + +(defn- evaluate-snippet! [{:keys [document line code expected]} ^Path socket] + (with-fixture-properties socket + #(let [name (symbol (str "libtmux.docs.generated." (UUID/randomUUID))) + namespace (create-ns name)] + (try + (binding [*ns* namespace] + (refer 'clojure.core) + (let [actual (eval (read-string (str "(do\n" code "\n)")))] + (when-not (= expected actual) + (throw (ex-info "Clojure documentation output differs from the evaluated result" + {:docs/error :output-mismatch + :document document :line line + :expected expected :actual actual}))) + actual)) + (finally + (remove-ns name)))))) + +(defn- run-snippet! [snippet] + (fixture/with-owned-server + (fn [{:keys [socket]}] + (evaluate-snippet! snippet socket)))) + +(defn- failure-data [f] + (try + (f) + nil + (catch clojure.lang.ExceptionInfo failure + (ex-data failure)))) + +(deftest required-documentation-is-present-and-discovered + (let [root (document-root) + snippets (documented-snippets root)] + (is (seq snippets)) + (is (= #{"libtmux-clojure/README.md" "docs/guide/clojure.md"} + (set (map :document snippets)))))) + +(deftest captured-field-reference-matches-the-projection + (is (= (projected-fields!) + (documented-fields! (document-root))))) + +(deftest documented-snippets-open-owned-clients-and-match-their-output + (doseq [snippet (documented-snippets (document-root))] + (is (= (:expected snippet) (run-snippet! snippet)) + (str (:document snippet) ":" (:line snippet))))) + +(deftest malformed-documentation-is-rejected-before-it-can-look-executable + (is (= :missing-ownership + (:docs/error (failure-data #(snippet! "guide.md" 1 "fixture" "(+ 1 1)\n;; => 2"))))) + (is (= :missing-or-ambiguous-output + (:docs/error (failure-data #(snippet! "guide.md" 1 "fixture" + "(with-open [server nil] 2)"))))) + (is (= :missing-or-unknown-directive + (:docs/error (failure-data #(snippet! "guide.md" 1 nil + "(with-open [server nil] 2)\n;; => 2"))))) + (let [directory (Files/createTempDirectory "libtmux-docs-" + (make-array java.nio.file.attribute.FileAttribute 0))] + (try + (is (= :missing-document + (:docs/error (failure-data #(require-documents! directory))))) + (finally + (Files/deleteIfExists directory))))) + +(deftest renamed-imports-and-wrong-displayed-output-fail-the-documentation-gate + (let [root (document-root) + snippet (first (documented-snippets root)) + renamed (assoc snippet :code "(require '[libtmux.missing :as tm])")] + (is (thrown? Throwable (run-snippet! renamed))) + (is (= :output-mismatch + (:docs/error (failure-data #(run-snippet! (assoc snippet :expected ::wrong)))))))) diff --git a/integration-tests/src/test/clojure/libtmux/internal/fixture.clj b/integration-tests/src/test/clojure/libtmux/internal/fixture.clj new file mode 100644 index 00000000..9710af0e --- /dev/null +++ b/integration-tests/src/test/clojure/libtmux/internal/fixture.clj @@ -0,0 +1,184 @@ +(ns libtmux.internal.fixture + (:import [io.github.libtmux Server ServerConfig ServerConfig$Builder ServerEndpoint] + [io.github.libtmux.junit5 NamedServerFixture] + [io.github.libtmux.transport CommandResult] + [java.lang AutoCloseable ProcessHandle ProcessHandle$Info] + [java.nio.file Files Path LinkOption] + [java.nio.file.attribute BasicFileAttributes] + [java.time Duration Instant] + [java.util UUID] + [java.util.concurrent TimeUnit])) + +(def ^:private ^Path socket-root (Path/of "/tmp/libtmux-java-test" (make-array String 0))) +(def ^:dynamic *after-start* (fn [_])) + +(defn- open-server [^Path socket ^Path config] + (let [^ServerConfig$Builder builder (ServerConfig/builder)] + (.binary builder (System/getProperty "libtmux.tmux" "tmux")) + (.endpoint builder (ServerEndpoint/socketPath socket)) + (.configFile builder config) + (.defaultTimeout builder (Duration/ofMillis 900)) + (Server/open (.build builder)))) + +(defn- command! [^Server server ^java.util.List argv] + (.cmd server argv)) + +(defn- close-suppressing! [resource primary] + (try + (.close ^AutoCloseable resource) + (catch Throwable cleanup + (if-let [^Throwable failure @primary] + (.addSuppressed failure cleanup) + (throw cleanup))))) + +(defn- remove-scratch! [^Path directory ^Path config] + (Files/deleteIfExists config) + (Files/deleteIfExists directory)) + +(defn- suppress! [primary cleanup] + (if-let [^Throwable failure @primary] + (.addSuppressed failure cleanup) + (throw cleanup))) + +(defn- process-identity [^ProcessHandle process] + (let [^ProcessHandle$Info info (.info process)] + {:process process + :pid (.pid process) + :command (.orElse (.command info) nil) + :arguments (vec (.orElse (.arguments info) (make-array String 0))) + :started (.orElse (.startInstant info) nil)})) + +(defn- basename [^String path] + (when path (str (.getFileName (Path/of path (make-array String 0)))))) + +(defn- tmux-for-socket? [identity binary ^Path socket] + (let [command (:command identity) + arguments (:arguments identity) + executable (basename command) + expected (basename binary) + socket (str (.toAbsolutePath socket))] + (and (= expected executable) + (some true? + (map-indexed + (fn [index argument] + (and (= "-S" argument) + (< (inc index) (count arguments)) + (= socket (nth arguments (inc index))))) + arguments))))) + +(defn- socket-key [^Path socket] + (let [^"[Ljava.nio.file.LinkOption;" options (into-array LinkOption [LinkOption/NOFOLLOW_LINKS]) + ^BasicFileAttributes attributes (Files/readAttributes socket BasicFileAttributes options)] + (.fileKey attributes))) + +(defn- capture-process [binary socket] + (let [matches (->> (iterator-seq (.iterator (ProcessHandle/allProcesses))) + (map process-identity) + (filter #(tmux-for-socket? % binary socket)) + vec)] + (when (> (count matches) 1) + (throw (ex-info "multiple tmux processes claim the fixture socket" + {:socket socket :pids (mapv :pid matches)}))) + (when-let [owned (first matches)] + (assoc owned :socket-key (socket-key socket))))) + +(defn- same-process? [owned] + (let [^ProcessHandle process (:process owned) + current (process-identity process)] + (= (select-keys owned [:pid :command :arguments :started]) + (select-keys current [:pid :command :arguments :started])))) + +(defn- stop-captured! [owned ^Path socket primary] + (when owned + (let [^ProcessHandle process (:process owned)] + (if-not (same-process? owned) + (suppress! primary + (ex-info "refusing to terminate a replacement tmux process" + {:socket socket :pid (:pid owned)})) + (do + (.destroy process) + (try + (.get (.onExit process) 900 TimeUnit/MILLISECONDS) + (catch Exception cleanup + (suppress! primary cleanup))) + (when (.isAlive process) + (suppress! primary + (ex-info "fixture tmux survived startup cleanup" + {:socket socket :pid (:pid owned)}))) + (when (and (not (.isAlive process)) + (Files/exists socket (make-array LinkOption 0))) + (if (and (:socket-key owned) (= (:socket-key owned) (socket-key socket))) + (Files/delete socket) + (suppress! primary (ex-info "refusing to reclaim a replacement fixture socket" + {:socket socket}))))))))) + +(defn with-owned-server [body] + (Files/createDirectories socket-root (make-array java.nio.file.attribute.FileAttribute 0)) + (let [directory (Files/createTempDirectory socket-root "clj-" + (make-array java.nio.file.attribute.FileAttribute 0)) + socket (.resolve directory "s") + config (.resolve directory "tmux.conf")] + (Files/writeString config "" (make-array java.nio.file.OpenOption 0)) + (let [^Server server + (try + (open-server socket config) + (catch Throwable failure + (try + (remove-scratch! directory config) + (catch Throwable cleanup + (.addSuppressed failure cleanup))) + (throw failure))) + primary (atom nil) + captured (atom nil) + binary (System/getProperty "libtmux.tmux" "tmux")] + (try + (let [^CommandResult created + (command! server ["new-session" "-d" "-s" + (str "clj-" (UUID/randomUUID))])] + (when-not (.succeeded created) + (throw (ex-info "could not start fixture tmux" {:stderr (.stderr created)})))) + (reset! captured (capture-process binary socket)) + (*after-start* socket) + (let [^NamedServerFixture owner (NamedServerFixture/own server socket socket-root)] + (try + (let [expected (System/getProperty "libtmux.tmux.expected") + ^CommandResult version-result + (command! server ["display-message" "-p" "#{version}"]) + actual (first (.stdout version-result))] + (when-not (.succeeded version-result) + (throw (ex-info "could not read fixture tmux version" + {:stderr (.stderr version-result)}))) + (when (and expected (not= expected actual)) + (throw (ex-info "fixture tmux does not match the selected matrix lane" + {:expected expected :actual actual}))) + (body {:server server + :socket (.socket owner) + :binary binary + :expected-version expected + :actual-version actual})) + (catch Throwable failure + (reset! primary failure) + (throw failure)) + (finally + (close-suppressing! owner primary)))) + (catch Throwable failure + (reset! primary failure) + (when-not @captured + (try + (reset! captured (capture-process binary socket)) + (catch Throwable cleanup + (.addSuppressed failure cleanup)))) + (when (and (nil? @captured) + (Files/exists socket (make-array java.nio.file.LinkOption 0))) + (.addSuppressed failure + (ex-info "refusing to terminate an unauthenticated tmux process" + {:socket socket}))) + (stop-captured! @captured socket primary) + (throw failure)) + (finally + (close-suppressing! server primary) + (when-not (Files/exists socket (make-array java.nio.file.LinkOption 0)) + (try + (remove-scratch! directory config) + (catch Throwable cleanup + (suppress! primary cleanup))))))))) diff --git a/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj b/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj new file mode 100644 index 00000000..cc9fca25 --- /dev/null +++ b/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj @@ -0,0 +1,163 @@ +(ns libtmux.internal.fixture-test + (:require [clojure.test :refer [deftest is]] + [libtmux.async :as async] + [libtmux.internal.fixture :as fixture]) + (:import [java.lang ProcessHandle Thread] + [java.nio.file Files Path] + [java.util.concurrent TimeUnit])) + +(defn- exists? [^Path socket] + (Files/exists socket + (make-array java.nio.file.LinkOption 0))) + +(defn- capture-fixture-process [binary socket] + (let [capture (ns-resolve 'libtmux.internal.fixture 'capture-process)] + (capture binary socket))) + +(defn- await-exit! [^ProcessHandle process] + (.get (.onExit process) 900 TimeUnit/MILLISECONDS)) + +(defn- replace-fixture-daemon! [^Path socket] + (let [binary (System/getProperty "libtmux.tmux" "tmux") + original (capture-fixture-process binary socket) + directory (.getParent socket) + config (.resolve directory "foreign.conf")] + (when-not original + (throw (ex-info "could not capture the fixture tmux process" {:socket socket}))) + (.destroy ^ProcessHandle (:process original)) + (await-exit! (:process original)) + (Files/deleteIfExists socket) + (Files/writeString config "" (make-array java.nio.file.OpenOption 0)) + (let [^java.util.List arguments [binary "-f" (str config) "-S" (str socket) + "new-session" "-d" "-s" "foreign-fixture"] + ^Process process (.start (ProcessBuilder. arguments)) + completed? (.waitFor process 900 TimeUnit/MILLISECONDS) + replacement (capture-fixture-process binary socket)] + (when-not (and completed? (zero? (.exitValue process)) replacement) + (throw (ex-info "could not start a replacement tmux process" {:socket socket}))) + (assoc replacement :socket socket :config config)))) + +(defn- interrupted-fixture-cleans! [options expected-error] + (let [started (promise) + release (java.util.concurrent.CountDownLatch. 1) + ^libtmux.async.AsyncRuntime runtime (async/runtime! {:close-timeout-ms 900}) + task (async/submit! runtime options + #(fixture/with-owned-server + (fn [{:keys [socket binary]}] + (let [owner (capture-fixture-process binary socket)] + (deliver started {:socket socket + :directory (.getParent ^Path socket) + :process (:process owner) + :worker (Thread/currentThread)}) + (.await release)))))] + (let [owned + (try + (let [owned (deref started 800 ::not-started)] + (is (not= ::not-started owned)) + (when (map? owned) + (is (instance? ProcessHandle (:process owned))) + (is (instance? Thread (:worker owned))) + (is (exists? (:socket owned)))) + (when (= expected-error :cancelled) + (is (true? (async/cancel! task)))) + (let [failure (try @task (catch Throwable failure failure))] + (is (= expected-error (:tmux/error (ex-data failure))))) + owned) + (finally + (.countDown release) + (try + (deref task 900 ::still-running) + (catch Throwable _)) + (async/close-runtime! runtime)))] + (when (map? owned) + (is (false? (.isAlive ^ProcessHandle (:process owned)))) + (is (false? (.isAlive ^Thread (:worker owned)))) + (is (not (exists? (:socket owned)))) + (is (not (exists? (:directory owned)))))))) + +(deftest owned-server-is-removed-after-success + (let [seen (atom nil)] + (fixture/with-owned-server + (fn [{:keys [socket] :as owned}] + (reset! seen socket) + (is (instance? io.github.libtmux.Server (:server owned))) + (is (exists? socket)))) + (is (not (exists? @seen))))) + +(deftest owned-server-is-removed-after-exception + (let [seen (atom nil)] + (is (thrown? Exception + (fixture/with-owned-server + (fn [{:keys [socket]}] + (reset! seen socket) + (throw (Exception. "fixture failure")))))) + (is (not (exists? @seen))))) + +(deftest server-is-removed-when-setup-fails-after-start + (let [seen (atom nil)] + (is (thrown? Exception + (binding [fixture/*after-start* + (fn [socket] + (reset! seen socket) + (throw (Exception. "setup failure")))] + (fixture/with-owned-server (fn [_]))))) + (is (not (exists? @seen))))) + +(deftest selected-version-must-match + (let [property "libtmux.tmux.expected" + previous (System/getProperty property)] + (try + (System/setProperty property "not-a-tmux-version") + (is (thrown-with-msg? clojure.lang.ExceptionInfo + #"does not match" + (fixture/with-owned-server (fn [_])))) + (finally + (if previous + (System/setProperty property previous) + (System/clearProperty property)))))) + +(deftest changed-process-identity-is-not-owned + (let [process (ProcessHandle/current) + owned {:process process + :pid (.pid process) + :command "tmux" + :arguments ["-S" "/tmp/libtmux-java-test/owned"] + :started nil} + same-process? (ns-resolve 'libtmux.internal.fixture 'same-process?) + identity (ns-resolve 'libtmux.internal.fixture 'process-identity)] + (with-redefs-fn {identity (fn [_] owned)} + #(is (true? (same-process? owned)))) + (with-redefs-fn {identity (fn [_] (assoc owned :pid (inc (:pid owned))))} + #(is (false? (same-process? owned)))))) + +(deftest replacement-daemon-survives-fixture-refusal + (let [replacement (atom nil) + failure (try + (binding [fixture/*after-start* + (fn [socket] + (reset! replacement (replace-fixture-daemon! socket)) + (throw (ex-info "fixture process was replaced" {:socket socket})))] + (fixture/with-owned-server (fn [_]))) + nil + (catch Throwable failure failure))] + (try + (is (instance? clojure.lang.ExceptionInfo failure)) + (is (some #(re-find #"replacement" (.getMessage ^Throwable %)) + (.getSuppressed ^Throwable failure))) + (when-let [owned @replacement] + (is (.isAlive ^ProcessHandle (:process owned))) + (is (exists? (:socket owned)))) + (finally + (when-let [owned @replacement] + (.destroy ^ProcessHandle (:process owned)) + (await-exit! (:process owned)) + (Files/deleteIfExists (:socket owned)) + (Files/deleteIfExists (:config owned)) + (Files/deleteIfExists (.resolve (.getParent ^Path (:socket owned)) "tmux.conf")) + (Files/deleteIfExists (.getParent ^Path (:socket owned)))))))) + +(deftest cancellation-reclaims-the-owned-fixture-resources + (interrupted-fixture-cleans! {} :cancelled)) + +(deftest deadline-reclaims-the-owned-fixture-resources + (interrupted-fixture-cleans! {:timeout-ms 800} :timeout)) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/CapturedServerIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/CapturedServerIntegrationTest.java new file mode 100644 index 00000000..075c47ee --- /dev/null +++ b/integration-tests/src/test/java/io/github/libtmux/it/CapturedServerIntegrationTest.java @@ -0,0 +1,73 @@ +package io.github.libtmux.it; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.libtmux.CapturedServer; +import io.github.libtmux.Server; +import io.github.libtmux.Window; +import io.github.libtmux.junit5.TmuxExtension; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.ProcessTransport; +import io.github.libtmux.transport.TmuxTransport; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(TmuxExtension.class) +final class CapturedServerIntegrationTest { + + @Test + void capturedHandlesRetainLinkedPlacementsAfterLiveChanges(Server server) { + assertTrue(server.cmd("new-session", "-d", "-s", "other").succeeded()); + assertTrue(server.cmd("link-window", "-s", "libtmux:0", "-t", "other:9").succeeded()); + AtomicInteger commands = new AtomicInteger(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport counting = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + commands.incrementAndGet(); + return processes.execute(request); + } + + @Override + public void close() {} + }; + try (Server measured = Server.using(server.config(), counting)) { + CapturedServer captured = measured.capture(); + assertEquals(2, commands.get()); + Window first = captured.sessions().stream() + .filter(session -> session.name().equals("libtmux")) + .findFirst() + .orElseThrow() + .windows() + .getFirst(); + List links = captured.windows().stream() + .filter(window -> window.id().equals(first.id())) + .toList(); + assertEquals(2, links.size()); + assertNotEquals(links.getFirst().context(), links.getLast().context()); + assertEquals( + links.getFirst().panes().getFirst().id(), + links.getLast().panes().getFirst().id()); + assertTrue(server.cmd("rename-window", "-t", "libtmux:0", "changed-after-capture") + .succeeded()); + assertEquals(first.name(), links.getLast().name()); + assertNotEquals("changed-after-capture", first.name()); + assertEquals( + captured.snapshot().serverPid(), captured.identity().processId()); + assertEquals( + captured.snapshot().clients().size(), captured.clients().size()); + captured.clients().forEach(client -> client.attachment()); + captured.panes().forEach(pane -> pane.window().session().windows()); + assertEquals(2, commands.get(), "captured clients and relations issue no further commands"); + assertEquals( + "changed-after-capture", + server.window(first.context()).orElseThrow().name()); + } + } + } +} diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ControlBytesIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ControlBytesIntegrationTest.java new file mode 100644 index 00000000..176b729d --- /dev/null +++ b/integration-tests/src/test/java/io/github/libtmux/it/ControlBytesIntegrationTest.java @@ -0,0 +1,80 @@ +package io.github.libtmux.it; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.libtmux.PaneId; +import io.github.libtmux.Server; +import io.github.libtmux.control.ControlClient; +import io.github.libtmux.control.EventSubscription; +import io.github.libtmux.control.PaneOutputBytes; +import io.github.libtmux.control.PaneOutputDecoder; +import io.github.libtmux.junit5.TmuxExtension; +import java.io.ByteArrayOutputStream; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(TmuxExtension.class) +final class ControlBytesIntegrationTest { + @Test + void splitUtf8AndInvalidBytesSurviveTheControlTransport(Server server) throws Exception { + try (var client = ControlClient.attach( + server.config(), server.sessions().getFirst().id()); + var events = client.subscribeOutputBytes(32, 4096)) { + var reply = client.send( + "new-window", + "-P", + "-F", + "#{pane_id}", + "stty -echo; printf 'READY'; read -r start; printf '\\303'; read -r more; printf '\\251\\377'; read -r finish"); + assertTrue(reply.succeeded()); + PaneId pane = new PaneId(reply.lines().getFirst()); + assertArrayEquals("READY".getBytes(StandardCharsets.US_ASCII), take(events, pane, 5)); + var decoder = new PaneOutputDecoder(CodingErrorAction.REPLACE); + client.send("send-keys", "-t", pane.value(), "Enter"); + byte[] first = take(events, pane, 1); + assertArrayEquals(new byte[] {(byte) 0xc3}, first); + assertEquals("", decoder.decode(new PaneOutputBytes(pane, first), 0).data()); + client.send("send-keys", "-t", pane.value(), "Enter"); + byte[] second = take(events, pane, 2); + assertArrayEquals(new byte[] {(byte) 0xa9, (byte) 0xff}, second); + assertEquals( + "é�", decoder.decode(new PaneOutputBytes(pane, second), 0).data()); + assertEquals(0, events.droppedCount()); + } + } + + @Test + void detachingAControlClientDoesNotClaimTheServerDied(Server server) throws Exception { + try (var client = ControlClient.attach( + server.config(), server.sessions().getFirst().id()); + var events = client.subscribeOutputBytes(4, 1024)) { + String name = client.send("display-message", "-p", "#{client_name}") + .lines() + .getFirst(); + assertTrue(server.cmd("detach-client", "-t", name).succeeded()); + while (events.next(Duration.ofSeconds(1)).isPresent()) {} + assertEquals( + EventSubscription.EndReason.CONTROL_EXIT, + events.termination().orElseThrow().reason()); + assertTrue(server.cmd("display-message", "-p", "still-alive").succeeded()); + } + } + + private static byte[] take(EventSubscription events, PaneId pane, int count) throws Exception { + var bytes = new ByteArrayOutputStream(); + long deadline = System.nanoTime() + Duration.ofSeconds(1).toNanos(); + while (bytes.size() < count) { + var next = events.next(Duration.ofNanos(Math.max(0, deadline - System.nanoTime()))) + .orElseThrow(); + if (next.pane().equals(pane)) { + bytes.write(next.data()); + } + } + return bytes.toByteArray(); + } +} diff --git a/libtmux-bom/README.md b/libtmux-bom/README.md index 2872c232..bfbbf1f0 100644 --- a/libtmux-bom/README.md +++ b/libtmux-bom/README.md @@ -61,6 +61,8 @@ doing its job. [`libtmux`](../libtmux/) · [`libtmux-jackson`](../libtmux-jackson/) · [`libtmux-junit5`](../libtmux-junit5/) · [`libtmux-kotlin`](../libtmux-kotlin/) · +[`libtmux-clojure`](../libtmux-clojure/) · +[`libtmux-clojure-core-async`](../libtmux-clojure-core-async/) · [`libtmux-mcp`](../libtmux-mcp/) · [`libtmux-workspace`](../libtmux-workspace/) That list is checked rather than trusted. `platformCoversEveryPublishedModule` diff --git a/libtmux-bom/build.gradle.kts b/libtmux-bom/build.gradle.kts index e6a18d72..f057f132 100644 --- a/libtmux-bom/build.gradle.kts +++ b/libtmux-bom/build.gradle.kts @@ -19,6 +19,8 @@ dependencies { api(project(":libtmux")) api(project(":libtmux-jackson")) api(project(":libtmux-kotlin")) + api(project(":libtmux-clojure")) + api(project(":libtmux-clojure-core-async")) api(project(":libtmux-junit5")) api(project(":libtmux-mcp")) api(project(":libtmux-workspace")) diff --git a/libtmux-clojure-core-async/README.md b/libtmux-clojure-core-async/README.md new file mode 100644 index 00000000..a48477c8 --- /dev/null +++ b/libtmux-clojure-core-async/README.md @@ -0,0 +1,25 @@ +# libtmux-clojure-core-async + +**Optional core.async integration for libtmux-clojure.** + +The Maven coordinate is `io.github.libtmux:libtmux-clojure-core-async`. Add it +only when an application uses `libtmux.core-async`; `libtmux-clojure` itself +does not depend on core.async. + +```xml + + io.github.libtmux + libtmux-clojure-core-async + 0.0.1-alpha.12 + +``` + +> **Alpha.** The API will change without notice. Pin an exact version; it is +> not recommended for production. + +Requires JDK 21 or newer and tmux 3.2a through 3.7c. + +The adapter owns its event and terminal channels. Its stop function closes its +Java subscription and must be called when the application is finished. The +[Clojure guide](../docs/guide/clojure.md) describes the ownership and loss +boundaries. diff --git a/libtmux-clojure-core-async/build.gradle.kts b/libtmux-clojure-core-async/build.gradle.kts new file mode 100644 index 00000000..93652b3d --- /dev/null +++ b/libtmux-clojure-core-async/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { id("libtmux.clojure-library") } + +extra["clojureTestNamespaces"] = + "libtmux.core-async-test,libtmux.internal.core-async-package-test" + +dependencies { + api(project(":libtmux-clojure")) + api(libs.core.async) +} + +tasks.withType().configureEach { + systemProperty("libtmux.clojure.main-namespaces", "libtmux.core-async") +} diff --git a/libtmux-clojure-core-async/src/main/clojure/libtmux/core_async.clj b/libtmux-clojure-core-async/src/main/clojure/libtmux/core_async.clj new file mode 100644 index 00000000..c2d10baf --- /dev/null +++ b/libtmux-clojure-core-async/src/main/clojure/libtmux/core_async.clj @@ -0,0 +1,136 @@ +(ns libtmux.core-async + "Closeable core.async observation adapter for libtmux control streams." + (:require [clojure.core.async :as async] + [clojure.core.async.impl.protocols :as protocols] + [libtmux.control :as control]) + (:import [java.lang AutoCloseable])) + +(set! *warn-on-reflection* true) + +(declare stop!) + +(deftype ^:private EventsPort [channel stop-fn] + protocols/ReadPort + (take! [_ handler] (protocols/take! channel handler)) + protocols/Channel + (close! [_] + (stop-fn) + (protocols/close! channel)) + (closed? [_] (protocols/closed? channel))) + +(deftype Adapter [events terminal subscription stop-channel stopped worker limits] + AutoCloseable + (close [this] (stop! this))) + +(defn events + "Returns the read-only events channel. Closing it stops the adapter." + [^Adapter adapter] + (.-events adapter)) + +(defn terminal + "Returns a buffered channel that delivers one outcome after cleanup." + [^Adapter adapter] + (.-terminal adapter)) + +(defn limits + "Returns the adapter's exact retained-event and retained-byte bounds." + [^Adapter adapter] + (.-limits adapter)) + +(defn stop! + "Stops observation and closes only the owned subscription and worker." + [adapter] + (when-not (instance? Adapter adapter) + (throw (IllegalArgumentException. "stop! requires a core.async adapter"))) + (io! + (when (compare-and-set! (.-stopped ^Adapter adapter) false true) + (async/close! (.-stop-channel ^Adapter adapter)) + (control/close! (.-subscription ^Adapter adapter))) + nil)) + +(defn- stopped-outcome [] + {:reason :adapter-stopped + :cause nil + :loss {:upstream/events 0 :upstream/bytes 0 + :adapter/events 0 :adapter/bytes 0}}) + +(defn- terminal-outcome [stopped result] + (if @stopped + (stopped-outcome) + (case (:stream/status result) + :terminal (:stream/terminal result) + (stopped-outcome)))) + +(defn observe! + "Starts an owned virtual worker over a borrowed control connection. + + `:event-capacity` defaults to zero. The worker can retain one pending event; + a fixed buffer retains that many more. `:upstream-max-bytes` bounds each + event through the Java subscription, so the adapter byte bound is + `(inc event-capacity) * upstream-max-bytes`. Closing the events channel or + adapter stops blocked reads and puts. The borrowed connection remains open." + ([connection] (observe! connection {})) + ([connection options] + (when-not (map? options) + (throw (IllegalArgumentException. "adapter options must be a map"))) + (let [allowed #{:event-capacity :upstream-capacity :upstream-max-bytes + :encoding :malformed :max-panes} + unknown (seq (remove allowed (keys options))) + {:keys [event-capacity upstream-capacity upstream-max-bytes encoding malformed max-panes] + :or {event-capacity 0 upstream-capacity 256 upstream-max-bytes 1048576 + encoding :bytes malformed :report max-panes 1024}} options] + (when (or unknown + (not (and (integer? event-capacity) (<= 0 event-capacity Integer/MAX_VALUE))) + (not (and (integer? upstream-capacity) (<= 1 upstream-capacity Integer/MAX_VALUE))) + (not (and (integer? upstream-max-bytes) (<= 1 upstream-max-bytes Long/MAX_VALUE)))) + (throw (IllegalArgumentException. "invalid core.async adapter options"))) + (io! + (let [subscription (control/subscribe! connection + {:capacity upstream-capacity + :max-bytes upstream-max-bytes + :encoding encoding + :malformed malformed + :max-panes max-panes}) + raw-events (async/chan (when (pos? event-capacity) + (async/buffer event-capacity))) + terminal-channel (async/chan 1) + stop-channel (async/chan) + stopped (atom false) + stop-fn #(when (compare-and-set! stopped false true) + (async/close! stop-channel) + (control/close! subscription)) + public-events (EventsPort. raw-events stop-fn) + outcome (atom nil) + worker (.start + (Thread/ofVirtual) + ^Runnable + (fn [] + (try + (loop [] + (let [result (control/next! subscription)] + (case (:stream/status result) + :timeout (recur) + :terminal (reset! outcome (terminal-outcome stopped result)) + :event (let [[accepted port] + (async/alts!! [[raw-events result] stop-channel] + :priority true)] + (when (and (identical? port raw-events) accepted) + (recur)))))) + (catch Throwable failure + (reset! outcome + {:reason :adapter-failure :cause failure + :loss {:upstream/events 0 :upstream/bytes 0 + :adapter/events 0 :adapter/bytes 0}})) + (finally + (try (control/close! subscription) + (catch Throwable cleanup + (if-let [^Throwable failure (:cause @outcome)] + (.addSuppressed failure cleanup) + (reset! outcome {:reason :cleanup-failure :cause cleanup})))) + (async/close! raw-events) + (async/>!! terminal-channel + (or @outcome (terminal-outcome stopped {:stream/status :stopped}))) + (async/close! terminal-channel)))))] + (Adapter. public-events terminal-channel subscription stop-channel stopped worker + {:events (inc event-capacity) + :bytes (*' (inc event-capacity) upstream-max-bytes)})))))) diff --git a/libtmux-clojure-core-async/src/test/clojure/libtmux/core_async_test.clj b/libtmux-clojure-core-async/src/test/clojure/libtmux/core_async_test.clj new file mode 100644 index 00000000..9f98e6e8 --- /dev/null +++ b/libtmux-clojure-core-async/src/test/clojure/libtmux/core_async_test.clj @@ -0,0 +1,88 @@ +(ns libtmux.core-async-test + (:require [clojure.core.async :as async] + [clojure.test :refer [deftest is]] + [libtmux.control :as control] + [libtmux.core-async :as adapter]) + (:import [java.util.concurrent CountDownLatch TimeUnit])) + +(defn- take-within [channel] + (let [timeout (async/timeout 500) + [value port] (async/alts!! [channel timeout] :priority true)] + (if (identical? port timeout) ::timeout value))) + +(deftest forwards-events-and-publishes-terminal-after-cleanup + (let [calls (atom 0) + closed (CountDownLatch. 1)] + (with-redefs [control/subscribe! (fn [_ _] ::subscription) + control/next! (fn [_] + (case (int (swap! calls inc)) + 1 {:stream/status :timeout} + 2 {:stream/status :event :output/bytes (byte-array [1 2]) + :loss {:upstream/events 3 :upstream/bytes 4 + :adapter/events 0 :adapter/bytes 0}} + {:stream/status :terminal + :stream/terminal {:reason :control-exit}})) + control/close! (fn [_] (.countDown closed))] + (let [owner (adapter/observe! ::connection)] + (is (= [1 2] (vec (:output/bytes (take-within (adapter/events owner)))))) + (is (= :control-exit (:reason (take-within (adapter/terminal owner))))) + (is (.await closed 500 TimeUnit/MILLISECONDS)) + (is (= {:events 1 :bytes 1048576} (adapter/limits owner))))))) + +(deftest consumer-close-reports-adapter-stop-after-a-java-read-returns + (let [entered (CountDownLatch. 1) + released (CountDownLatch. 1)] + (with-redefs [control/subscribe! (fn [_ _] ::subscription) + control/next! (fn [_] + (.countDown entered) + (.await released) + {:stream/status :terminal + :stream/terminal {:reason :closed}}) + control/close! (fn [_] (.countDown released))] + (let [owner (adapter/observe! ::connection)] + (is (.await entered 500 TimeUnit/MILLISECONDS)) + (async/close! (adapter/events owner)) + (is (= :adapter-stopped (:reason (take-within (adapter/terminal owner))))))))) + +(deftest consumer-close-stops-worker-blocked-on-event-put + (let [read-count (atom 0) + cleaned (CountDownLatch. 1)] + (with-redefs [control/subscribe! (fn [_ _] ::subscription) + control/next! (fn [_] + (swap! read-count inc) + {:stream/status :event :output/bytes (byte-array [1])}) + control/close! (fn [_] (.countDown cleaned))] + (let [owner (adapter/observe! ::connection)] + ;; No consumer receives the unbuffered event; close must win the alts!!. + (async/close! (adapter/events owner)) + (is (= :adapter-stopped (:reason (take-within (adapter/terminal owner))))) + (is (.await cleaned 500 TimeUnit/MILLISECONDS)) + (is (<= @read-count 1)))))) + +(deftest owner-stop-wins-a-blocked-event-put + (let [offered (CountDownLatch. 1)] + (with-redefs [control/subscribe! (fn [_ _] ::subscription) + control/next! (fn [_] + (.countDown offered) + {:stream/status :event :output/bytes (byte-array [1])}) + control/close! (fn [_])] + (let [owner (adapter/observe! ::connection)] + (is (.await offered 500 TimeUnit/MILLISECONDS)) + (adapter/stop! owner) + (is (= :adapter-stopped (:reason (take-within (adapter/terminal owner))))))))) + +(deftest worker-failure-is-terminal-and-does-not-close-borrowed-connection + (let [subscription-closes (atom 0)] + (with-redefs [control/subscribe! (fn [_ _] ::subscription) + control/next! (fn [_] (throw (ex-info "boom" {:source :test}))) + control/close! (fn [_] (swap! subscription-closes inc))] + (let [owner (adapter/observe! ::borrowed) + terminal (take-within (adapter/terminal owner))] + (is (= :adapter-failure (:reason terminal))) + (is (= :test (:source (ex-data (:cause terminal))))) + (is (= 1 @subscription-closes)))))) + +(deftest validation-precedes-subscription + (is (thrown? IllegalArgumentException (adapter/observe! ::connection {:event-capacity -1}))) + (is (thrown? IllegalArgumentException (adapter/observe! ::connection {:unknown true}))) + (is (thrown? IllegalArgumentException (adapter/stop! nil)))) diff --git a/libtmux-clojure-core-async/src/test/clojure/libtmux/internal/core_async_package_test.clj b/libtmux-clojure-core-async/src/test/clojure/libtmux/internal/core_async_package_test.clj new file mode 100644 index 00000000..7a4cf04e --- /dev/null +++ b/libtmux-clojure-core-async/src/test/clojure/libtmux/internal/core_async_package_test.clj @@ -0,0 +1,9 @@ +(ns libtmux.internal.core-async-package-test + (:require [clojure.core.async :as async] + [clojure.test :refer [deftest is]])) + +(deftest core-async-is-on-the-runtime-classpath + (let [channel (async/chan 1)] + (is (true? (async/offer! channel :ready))) + (is (= :ready (async/poll! channel))) + (async/close! channel))) diff --git a/libtmux-clojure/README.md b/libtmux-clojure/README.md new file mode 100644 index 00000000..0ca1bf46 --- /dev/null +++ b/libtmux-clojure/README.md @@ -0,0 +1,92 @@ +# libtmux-clojure + +**A Clojure façade over libtmux-java.** + +It returns captured persistent maps and vectors, with the Java handle retained +at `:tmux/ref` for an explicit effect. Ordinary Clojure collection operations +work on the captured values without acquiring new tmux state. + +> **Alpha.** The API will change without notice. Pin an exact version; it is +> not recommended for production. + +Requires JDK 21 or newer and tmux 3.2a through 3.7c. + +## Install + +Add the direct coordinate to `deps.edn`: + +```edn +{:deps {io.github.libtmux/libtmux-clojure + {:mvn/version "0.0.1-alpha.12"}}} +``` + +The Maven coordinate is `io.github.libtmux:libtmux-clojure`. The Java BOM also +manages it when a Java build owns versions: + +```xml + + io.github.libtmux + libtmux-clojure + 0.0.1-alpha.12 + +``` + +## Connect and acquire + +`open!` opens a local client. It does not start or stop the tmux daemon. +Every acquisition function ends in `!`; traversal through a captured map is +local. The example uses the socket property supplied by the executable +documentation fixture. An application supplies its own explicit path. + + +```clojure +(require '[libtmux.core :as tm]) + +(let [socket-path (java.nio.file.Path/of + (System/getProperty "libtmux.docs.socket") + (make-array String 0))] + (with-open [server (tm/open! {:socket-path socket-path})] + (:session/name (tm/new-session! server {:name "docs-connect"})))) +;; => "docs-connect" +``` + +`with-open` releases the client when its body finishes. It never kills the +tmux daemon. `kill-server!` is the explicit operation that does that. + +## Values and effects + +Values are observations. Editing a map changes only that map. An operation +must receive its preserved `:tmux/ref`, and a refresh returns a new observation. + + +```clojure +(require '[libtmux.core :as tm] + '[libtmux.data :as data]) + +(let [socket-path (java.nio.file.Path/of + (System/getProperty "libtmux.docs.socket") + (make-array String 0))] + (with-open [server (tm/open! {:socket-path socket-path})] + (let [session (tm/new-session! server {:name "docs-identity"})] + (data/same-entity? session (tm/refresh! (:tmux/ref session)))))) +;; => true +``` + +Use `data/data` when a value must leave the operational boundary; it removes +every `:tmux/ref` and rejects unsupported JVM objects. + +## Reference + +- [`libtmux.core`](src/main/clojure/libtmux/core.clj): acquisition, effects, + errors, options, buffers and command groups. +- [`libtmux.data`](src/main/clojure/libtmux/data.clj): captured values, + identity, projections, cardinality and Java predicate adapters. +- [`libtmux.async`](src/main/clojure/libtmux/async.clj): bounded owned tasks + and `CompletionStage` interop. +- [`libtmux.control`](src/main/clojure/libtmux/control.clj): explicit control + replies and bounded event observation. + +[`docs/guide/clojure.md`](../docs/guide/clojure.md) covers filtering, links, +error metadata, lifecycle, execution modes, streaming and Java interop. +[`docs/guide/clojure-reference.md`](../docs/guide/clojure-reference.md) lists +captured keys, relations and Java filter support. diff --git a/libtmux-clojure/build.gradle.kts b/libtmux-clojure/build.gradle.kts new file mode 100644 index 00000000..53b38301 --- /dev/null +++ b/libtmux-clojure/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { id("libtmux.clojure-library") } + +extra["clojureTestNamespaces"] = + "libtmux.async-test,libtmux.control-test,libtmux.core-test,libtmux.data-test,libtmux.internal.package-test" + +dependencies { api(project(":libtmux")) } + +tasks.withType().configureEach { + systemProperty( + "libtmux.clojure.main-namespaces", + "libtmux.core,libtmux.data,libtmux.async,libtmux.control", + ) +} diff --git a/libtmux-clojure/src/main/clojure/libtmux/async.clj b/libtmux-clojure/src/main/clojure/libtmux/async.clj new file mode 100644 index 00000000..fe97af00 --- /dev/null +++ b/libtmux-clojure/src/main/clojure/libtmux/async.clj @@ -0,0 +1,331 @@ +(ns libtmux.async + (:require [libtmux.core :as core]) + (:import [java.lang AutoCloseable] + [java.util.concurrent ArrayBlockingQueue Callable CompletableFuture CountDownLatch Executor ExecutorService + Executors Future FutureTask RejectedExecutionException ScheduledExecutorService ScheduledFuture Semaphore ThreadFactory + ScheduledThreadPoolExecutor ThreadPoolExecutor ThreadPoolExecutor$AbortPolicy TimeUnit] + [java.util.function BiConsumer Function])) + +(declare cancel! close-runtime!) + +(deftype Task [state started worker deadline stage kind runtime cancellation permits publication-permit] + clojure.lang.IDeref + (deref [_] + (try (.get ^CompletableFuture stage) + (catch java.util.concurrent.ExecutionException failure (throw (.getCause failure))))) + clojure.lang.IBlockingDeref + (deref [_ timeout-ms timeout-value] + (try (.get ^CompletableFuture stage timeout-ms TimeUnit/MILLISECONDS) + (catch java.util.concurrent.TimeoutException _ timeout-value) + (catch java.util.concurrent.ExecutionException failure (throw (.getCause failure))))) + clojure.lang.IPending + (isRealized [_] (.isDone ^CompletableFuture stage))) + +(deftype AsyncRuntime [^ExecutorService ordinary ^ExecutorService waits + ^ExecutorService callbacks ^ExecutorService completions + ^ScheduledExecutorService deadlines + ^Semaphore admission ^Semaphore wait-admission ^Semaphore callback-admission + ^Semaphore publication-admission + tasks callback-stages lifecycle-lock + closed close-timeout-ms] + AutoCloseable + (close [this] (close-runtime! this))) + +(defn- virtual-factory [prefix] + (.factory (.name (Thread/ofVirtual) (str prefix "-") 0))) + +(defn- executor [workers queue-limit prefix] + (ThreadPoolExecutor. workers workers 0 TimeUnit/MILLISECONDS + (ArrayBlockingQueue. queue-limit) + ^ThreadFactory (virtual-factory prefix) + (ThreadPoolExecutor$AbortPolicy.))) + +(defn- completion-executor [] + (Executors/newThreadPerTaskExecutor (virtual-factory "libtmux-complete"))) + +(defn- deadline-executor [] + (doto (ScheduledThreadPoolExecutor. 1 ^ThreadFactory (virtual-factory "libtmux-deadline")) + (.setRemoveOnCancelPolicy true))) + +(defn- execute! [^ExecutorService executor ^Runnable task] + (.execute executor task)) + +(defn runtime! + "Creates an owned bounded runtime. Close it to reclaim every owned executor." + ([] (runtime! {})) + ([options] + (when-not (map? options) + (throw (IllegalArgumentException. "runtime options must be a map"))) + (let [allowed #{:max-running :queue-limit :close-timeout-ms} + unknown (seq (remove allowed (keys options))) + {:keys [max-running queue-limit close-timeout-ms] + :or {max-running 4 queue-limit 64 close-timeout-ms 900}} options] + (io! + (when unknown (throw (IllegalArgumentException. (str "unknown runtime options: " unknown)))) + (when-not (and (integer? max-running) (<= 2 max-running Integer/MAX_VALUE)) + (throw (IllegalArgumentException. "max-running must be at least 2"))) + (when-not (and (integer? queue-limit) (<= 1 queue-limit Integer/MAX_VALUE) + (integer? close-timeout-ms) (<= 1 close-timeout-ms Long/MAX_VALUE)) + (throw (IllegalArgumentException. "queue-limit and close-timeout-ms must be positive"))) + (AsyncRuntime. (executor (dec max-running) queue-limit "libtmux-work") + (executor 1 queue-limit "libtmux-wait") + (executor 1 queue-limit "libtmux-callback") + (completion-executor) + (deadline-executor) + (Semaphore. (+ max-running queue-limit)) + (Semaphore. (dec (+ max-running queue-limit))) + (Semaphore. (inc queue-limit)) + (Semaphore. (+ max-running queue-limit)) + (atom #{}) (atom #{}) (Object.) (atom false) close-timeout-ms))))) + +(defn task-kind [^Task task] (.-kind task)) +(defn task-state [^Task task] @(.-state task)) +(defn completion-stage [^Task task] (.minimalCompletionStage ^CompletableFuture (.-stage task))) + +(defn- failure [reason certainty] + (ex-info (if (= reason :timeout) "task deadline expired" "task cancelled") + {:tmux/error reason :tmux/operation :async-task :tmux/phase :execution + :tmux/dispatch certainty})) + +(defn- reconcile-failure [cancellation thrown] + (cond + (nil? cancellation) thrown + (or (nil? thrown) (identical? cancellation thrown)) cancellation + :else + ;; Cancellation remains the terminal classification and certainty, while + ;; structured partial-effect evidence and the original cause stay reachable. + (ex-info (.getMessage ^Throwable cancellation) + (merge (ex-data thrown) (ex-data cancellation)) + thrown))) + +(defn- finish! [^Task task throwable value] + (locking task + (let [state (.-state task) + ^AsyncRuntime runtime (.-runtime task) + throwable (reconcile-failure @(.-cancellation task) throwable)] + (when-not (= :terminal @state) + (reset! state :cleanup-pending) + (when-let [^ScheduledFuture timer @(.-deadline task)] (.cancel timer false)) + (let [ready (CountDownLatch. 1)] + (locking (.-lifecycle-lock runtime) + ;; The publisher cannot be shut down while this task remains in the + ;; registry. Acceptance therefore precedes the final registry drop. + (.execute ^ExecutorService (.-completions runtime) + ^Runnable #(do (.await ready) + (try + (if throwable + (.completeExceptionally ^CompletableFuture (.-stage task) throwable) + (.complete ^CompletableFuture (.-stage task) value)) + (finally + (.release ^Semaphore (.-publication-permit task)))))) + (swap! (.-tasks runtime) disj task) + (doseq [^Semaphore permit (.-permits task)] (.release permit)) + (reset! state :terminal) + (.countDown ready))))))) + +(defn- interrupt! [^Task task] + (when-let [^Future future @(.-worker task)] + (let [^ThreadPoolExecutor pool (if (= :wait (.-kind task)) + (.-waits ^AsyncRuntime (.-runtime task)) + (.-ordinary ^AsyncRuntime (.-runtime task)))] + (.cancel future true) + (.remove pool ^Runnable future)))) + +(defn- request-cancel! [^Task task reason] + (let [accepted (locking task + (let [state (.-state task)] + (when (#{:queued :running} @state) + (let [problem (failure reason (if (= :queued @state) + :not-dispatched :unknown))] + (reset! (.-cancellation task) problem) + (reset! state :cancelling) + problem))))] + (if accepted + (do (interrupt! task) true) + false))) + +(defn- reject! [^Task task] + (let [problem (failure :busy :not-dispatched) + cancellable? (locking task + (when (= :queued @(.-state task)) + (reset! (.-cancellation task) problem) + (reset! (.-state task) :cancelling)) + (= :cancelling @(.-state task)))] + (when cancellable? + ;; This FutureTask was never accepted by an executor. Cancellation calls + ;; done synchronously, which is the task's single finalization owner. + (.cancel ^Future @(.-worker task) false)))) + +(defn cancel! + "Requests cancellation. The first accepted cancellation reason wins." + [task] + (when-not (instance? Task task) + (throw (IllegalArgumentException. "cancel! requires a task"))) + (io! (request-cancel! task :cancelled))) + +(defn submit! + "Submits a zero-argument function, returning a Task or a :busy result." + ([runtime thunk] (submit! runtime {} thunk)) + ([^AsyncRuntime runtime options thunk] + (when-not (and (instance? AsyncRuntime runtime) (map? options) (ifn? thunk)) + (throw (IllegalArgumentException. "submit! requires a runtime, option map, and function"))) + (let [allowed #{:kind :timeout-ms} + unknown (seq (remove allowed (keys options))) + {:keys [kind timeout-ms] :or {kind :ordinary}} options] + (io! + (when unknown (throw (IllegalArgumentException. (str "unknown submit options: " unknown)))) + (when @(.-closed runtime) (throw (IllegalStateException. "runtime is closed"))) + (when-not (#{:ordinary :wait} kind) + (throw (IllegalArgumentException. "task kind must be :ordinary or :wait"))) + (when-not (or (nil? timeout-ms) + (and (integer? timeout-ms) (<= 1 timeout-ms Long/MAX_VALUE))) + (throw (IllegalArgumentException. "timeout-ms must be a positive integer"))) + (let [global (.-admission runtime) + wait-permit (when (= :wait kind) (.-wait-admission runtime)) + publication-permit (.-publication-admission runtime) + acquired-global? (.tryAcquire ^Semaphore global) + acquired-wait? (or (nil? wait-permit) (.tryAcquire ^Semaphore wait-permit)) + acquired-publication? (and acquired-global? acquired-wait? + (.tryAcquire ^Semaphore publication-permit))] + (if-not (and acquired-global? acquired-wait? acquired-publication?) + (do (when acquired-global? (.release ^Semaphore global)) + (when (and wait-permit acquired-wait?) (.release ^Semaphore wait-permit)) + (when acquired-publication? (.release ^Semaphore publication-permit)) + {:status :busy :kind kind} + ) + (let [state (atom :queued) started (atom false) worker (atom nil) deadline (atom nil) stage (CompletableFuture.) + cancellation (atom nil) + task (Task. state started worker deadline stage kind runtime cancellation + (cond-> [global] wait-permit (conj wait-permit)) publication-permit) + bindings (get-thread-bindings) + pool (if (= :wait kind) (.-waits runtime) (.-ordinary runtime)) + check (fn [{operation-kind :kind}] + (when-let [problem @cancellation] (throw problem)) + (when (and (= operation-kind :wait) (not= kind :wait)) + (throw (ex-info "wait operation requires a :wait task" + {:tmux/error :invalid-task-kind + :tmux/operation :async-task + :tmux/phase :validation + :tmux/dispatch :not-dispatched})))) + call (reify Callable + (call [_] + (let [run? (locking task + (when (= :queued @state) + (reset! started true) + (reset! state :running) + true))] + (if run? + (try + (let [value (with-bindings (assoc bindings + #'core/*execution-check* check) + (thunk))] + (finish! task @cancellation value)) + (catch Throwable thrown + (finish! task thrown nil))) + (finish! task @cancellation nil))) + nil)) + future (proxy [FutureTask] [call] + (done [] + ;; FutureTask invokes done even when cancellation wins + ;; after dequeue but before Callable.call. + (when (and (not @started) @cancellation) + (finish! task @cancellation nil))))] + (let [rejected? (atom false)] + (locking (.-lifecycle-lock runtime) + (if @(.-closed runtime) + (do + (doseq [^Semaphore permit (.-permits task)] (.release permit)) + (.release ^Semaphore (.-publication-permit task)) + (throw (IllegalStateException. "runtime is closed"))) + (do + (swap! (.-tasks runtime) conj task) + (when timeout-ms + (reset! deadline + (.schedule ^ScheduledExecutorService (.-deadlines runtime) + ^Runnable #(request-cancel! task :timeout) + (long timeout-ms) TimeUnit/MILLISECONDS))) + (reset! worker future) + (try + (execute! pool future) + (catch RejectedExecutionException _ + (reset! rejected? true)))))) + (if @rejected? + (do (reject! task) {:status :busy :kind kind}) + task))))))))) + +(defn then! [^AsyncRuntime runtime ^Task task f] + (when-not (and (instance? AsyncRuntime runtime) (instance? Task task) (ifn? f)) + (throw (IllegalArgumentException. "then! requires a runtime, task, and function"))) + (io! + (locking (.-lifecycle-lock runtime) + (when @(.-closed runtime) (throw (IllegalStateException. "runtime is closed"))) + (when-not (.tryAcquire ^Semaphore (.-callback-admission runtime)) + (throw (ex-info "callback capacity is full" + {:tmux/error :busy :tmux/operation :async/then + :tmux/phase :admission :tmux/dispatch :not-dispatched}))) + (try + (let [bindings (get-thread-bindings) + stage (.thenApplyAsync ^CompletableFuture (.-stage task) + (reify Function + (apply [_ value] (with-bindings bindings (f value)))) + ^Executor (.-callbacks runtime))] + (swap! (.-callback-stages runtime) conj stage) + (.whenComplete ^CompletableFuture stage + (reify BiConsumer + (accept [_ _ _] + (swap! (.-callback-stages runtime) disj stage) + (.release ^Semaphore (.-callback-admission runtime))))) + (.minimalCompletionStage ^CompletableFuture stage)) + (catch Throwable failure + (.release ^Semaphore (.-callback-admission runtime)) + (throw failure)))))) + +(defn- shutdown-completion-if-idle! [^AsyncRuntime runtime] + (locking (.-lifecycle-lock runtime) + (when (and (empty? @(.-tasks runtime)) + (empty? @(.-callback-stages runtime))) + (.shutdown ^ExecutorService (.-completions runtime))))) + +(defn close-runtime! [^AsyncRuntime runtime] + (when-not (instance? AsyncRuntime runtime) + (throw (IllegalArgumentException. "close-runtime! requires a runtime"))) + (io! + (let [deadline (+ (System/nanoTime) + (.toNanos TimeUnit/MILLISECONDS (.-close-timeout-ms runtime))) + workers [(.-ordinary runtime) (.-waits runtime) + (.-callbacks runtime) (.-deadlines runtime)] + ^ExecutorService completion (.-completions runtime) + first-close? (locking (.-lifecycle-lock runtime) + (compare-and-set! (.-closed runtime) false true))] + ;; Never hold the runtime lock while cancellation can finalize a task back + ;; into the registry. + (doseq [task @(.-tasks runtime)] (cancel! task)) + (when first-close? + (locking (.-lifecycle-lock runtime) + (doseq [^CompletableFuture stage @(.-callback-stages runtime)] + (.execute completion + ^Runnable #(.completeExceptionally stage (failure :cancelled :unknown))))) + (doseq [^ExecutorService executor workers] (.shutdownNow executor))) + (doseq [^ExecutorService executor workers] + (.awaitTermination executor + (max 0 (.toMillis TimeUnit/NANOSECONDS + (- deadline (System/nanoTime)))) + TimeUnit/MILLISECONDS)) + (shutdown-completion-if-idle! runtime) + (when (.isShutdown completion) + (.awaitTermination completion + (max 0 (.toMillis TimeUnit/NANOSECONDS + (- deadline (System/nanoTime)))) + TimeUnit/MILLISECONDS)) + (when (or (seq @(.-tasks runtime)) + (seq @(.-callback-stages runtime)) + (some #(not (.isTerminated ^ExecutorService %)) workers) + (not (.isTerminated completion))) + (throw (ex-info "runtime cleanup is still pending" + {:tmux/error :cleanup-pending + :tmux/operation :close-runtime + :tmux/phase :cleanup + :tmux/dispatch :unknown + :tasks (count @(.-tasks runtime)) + :callbacks (count @(.-callback-stages runtime))})))) + nil)) diff --git a/libtmux-clojure/src/main/clojure/libtmux/control.clj b/libtmux-clojure/src/main/clojure/libtmux/control.clj new file mode 100644 index 00000000..f574ce30 --- /dev/null +++ b/libtmux-clojure/src/main/clojure/libtmux/control.clj @@ -0,0 +1,245 @@ +(ns libtmux.control + "Explicit control replies and owned, bounded pane-output subscriptions." + (:require [libtmux.core :as tmux]) + (:import [io.github.libtmux Session Server PaneId LibTmuxException] + [io.github.libtmux.control ControlClient ControlReply EventSubscription + EventSubscription$Delivery EventSubscription$Termination PaneOutputBytes PaneOutputDecoder] + [io.github.libtmux.transport TmuxTransportException TmuxTimeoutException] + [java.lang AutoCloseable] + [java.nio.charset CharacterCodingException CodingErrorAction] + [java.time Duration] + [java.util Optional] + [java.util.concurrent TimeUnit] + [java.util.concurrent.locks ReentrantLock])) + +(set! *warn-on-reflection* true) + +(defn- invalid! [operation] + (throw (ex-info "Invalid control operation arguments" + {:tmux/error :tmux/validation :tmux/operation operation + :tmux/phase :validation :tmux/dispatch :not-dispatched}))) + +(defn- positive? [value limit] + (and (integer? value) (<= 1 value limit))) + +(defn- duration? [value] + (and (instance? Duration value) (not (.isNegative ^Duration value)))) + +(defn- perform! [operation f] + (io! + (when tmux/*execution-check* + (tmux/*execution-check* {:operation operation :kind (if (= operation :control/next) :wait :mutation)})) + (try (f) + (catch LibTmuxException cause + (throw (ex-info "Control operation failed" + {:tmux/error (if (instance? TmuxTimeoutException cause) + :tmux/timeout :tmux/transport) + :tmux/operation operation :tmux/phase :protocol + :tmux/dispatch + (if (instance? TmuxTransportException cause) + (case (.name (.outcome ^TmuxTransportException cause)) + "NOT_DISPATCHED" :not-dispatched + :unknown) + :unknown)} cause)))))) + +(deftype ^:private Connection [^ControlClient client identity] + AutoCloseable + (close [_] (io! (.close client)))) +(alter-meta! #'->Connection assoc :private true) + +(defn attach! + "Attaches an owned control client to an explicit captured Session reference. + Close releases this client, never its server. Use a dedicated connection + when cancellation must not affect other requests and subscriptions." + ([session] (attach! session (Duration/ofMillis 900))) + ([session timeout] + (when-not (and (instance? Session session) (duration? timeout) + (not (.isZero ^Duration timeout))) + (invalid! :control/attach)) + (perform! :control/attach + #(let [captured (tmux/refresh! session) + ^Session reference (:tmux/ref captured)] + (when-not reference + (throw (ex-info "Control session no longer exists" + {:tmux/error :tmux/not-found + :tmux/operation :control/attach + :tmux/phase :validation :tmux/dispatch :not-dispatched}))) + (Connection. (ControlClient/attach (.config (.server reference)) + (.id reference) timeout) + (dissoc (:tmux/identity captured) :tmux/kind :tmux/id)))))) + +(defn- reply-data [^ControlReply reply] + {:reply/status (case (.name (.outcome reply)) + "COMPLETE" :complete "FAILED" :failed + "SKIPPED" :skipped :unknown) + :reply/lines (vec (.lines reply)) + :operation/completion :unverified}) + +(defn send! + "Sends argv and returns protocol status, lines, and unverified completion. + A complete reply does not prove deferred work finished. Cancelling an active + send can end this connection and its other requests and subscriptions." + ([connection argv] (send! connection argv (Duration/ofMillis 900))) + ([connection argv timeout] + (when-not (and (instance? Connection connection) + (vector? argv) (seq argv) (every? string? argv) + (duration? timeout) (not (.isZero ^Duration timeout))) + (invalid! :control/send)) + (perform! :control/send + #(reply-data (.send ^ControlClient (.-client ^Connection connection) + ^java.util.List argv timeout))))) + +(deftype ^:private OutputStream [^EventSubscription subscription + ^PaneOutputDecoder decoder identity options state read-lock] + AutoCloseable + (close [_] (io! (.close subscription)))) +(alter-meta! #'->OutputStream assoc :private true) + +(defn subscribe! + "Creates a closeable output subscription borrowing its connection. + :encoding is :bytes (caller-owned byte arrays) or :utf-8. Text uses explicit + :malformed :report or :replace and resets after loss. :capacity, :max-bytes, + and :max-panes bound queued events, bytes, and text decoder identities. + Readers compete; create separate subscriptions for independent fan-out." + ([connection] (subscribe! connection {})) + ([connection options] + (when-not (and (instance? Connection connection) (map? options) + (every? #{:encoding :malformed :capacity :max-bytes :max-panes} (keys options))) + (invalid! :control/subscribe)) + (let [options (merge {:encoding :bytes :malformed :report :capacity 256 + :max-bytes 1048576 :max-panes 1024} options) + {:keys [encoding malformed capacity max-bytes max-panes]} options] + (when-not (and (#{:bytes :utf-8} encoding) (#{:report :replace} malformed) + (positive? capacity Integer/MAX_VALUE) + (positive? max-bytes Long/MAX_VALUE) + (positive? max-panes Integer/MAX_VALUE)) + (invalid! :control/subscribe)) + (perform! :control/subscribe + #(OutputStream. + (.subscribeOutputBytes ^ControlClient (.-client ^Connection connection) (int capacity) (long max-bytes)) + (when (= :utf-8 encoding) + (PaneOutputDecoder. (if (= :report malformed) + CodingErrorAction/REPORT CodingErrorAction/REPLACE))) + (.-identity ^Connection connection) options + (atom {:sequence 0 :panes #{} :decoder-resets 0 :loss 0}) (ReentrantLock.)))))) + +(defn- loss-data [^EventSubscription subscription] + {:upstream/events (.droppedCount subscription) + :upstream/bytes (.droppedBytes subscription) + :adapter/events 0 :adapter/bytes 0}) + +(defn- reason [^EventSubscription$Termination ended] + (case (.name (.reason ended)) + "CLOSED" :closed "CONTROL_EXIT" :control-exit + "TRANSPORT_FAILURE" :transport-failure "PROTOCOL_FAILURE" :protocol-failure :unknown)) + +(defn terminal + "Returns the single recorded stream outcome, or nil until a reader drains it. + :control-exit means the client ended; it does not assert daemon death. + The original :cause, when present, may contain sensitive information." + [^OutputStream stream] + (:terminal @(.-state stream))) + +(defn- end! + ([stream failure] (end! stream failure nil)) + ([^OutputStream stream failure trailing] + (let [^EventSubscription subscription (.-subscription stream) + ^EventSubscription$Termination ended (.orElse (.termination subscription) nil) + outcome (cond-> {:reason (if failure :decode-failure (if ended (reason ended) :unknown)) + :cause (or failure (when ended (.orElse (.cause ended) nil))) + :loss (loss-data subscription) + :decoder/resets (:decoder-resets @(.-state stream))} + trailing (assoc :output/trailing trailing))] + (:terminal (swap! (.-state stream) + #(if (:terminal %) % (assoc % :terminal outcome))))))) + +(defn- finish! [^OutputStream stream] + (let [^PaneOutputDecoder decoder (.-decoder stream) + subscription (.-subscription stream) + upstream (.orElse (.termination ^EventSubscription subscription) nil) + intentional? (and upstream (= :closed (reason upstream)))] + (if (or (nil? decoder) intentional?) + (do (when decoder (.reset decoder)) (end! stream nil)) + (try + (when (> (.droppedCount ^EventSubscription subscription) (:loss @(.-state stream))) + (.reset decoder) + (swap! (.-state stream) #(-> % (assoc :panes #{}) (update :decoder-resets inc)))) + (let [trailing (into [] + (keep (fn [^PaneId pane] + (let [text (.data (.finish decoder pane))] + (when (seq text) + {:pane/id (.value pane) :output/text text})))) + (:panes @(.-state stream)))] + (end! stream nil trailing)) + (catch CharacterCodingException failure (end! stream failure)))))) + +(defn- event-data [^OutputStream stream ^EventSubscription$Delivery delivery] + (let [^PaneOutputBytes event (.event delivery) + ^PaneOutputDecoder decoder (.-decoder stream) + pane (.pane event) + state (.-state stream) + loss (.droppedCount delivery) + reset? (and decoder (not (contains? (:panes @state) pane)) + (>= (count (:panes @state)) (:max-panes (.-options stream))))] + (when (and decoder (or reset? (> loss (:loss @state)))) + (.reset decoder) + (swap! state #(-> % (assoc :panes #{}) (update :decoder-resets inc)))) + (let [value (if decoder (.data (.decode decoder delivery)) (.data event)) + current (swap! state #(cond-> (-> % (update :sequence inc) (assoc :loss loss)) + decoder (update :panes conj pane)))] + {:stream/status :event + :event/sequence (:sequence current) + :tmux/identity (assoc (.-identity stream) :tmux/kind :pane :tmux/id (.value pane)) + :pane/id (.value pane) + (if decoder :output/text :output/bytes) value + :loss {:upstream/events loss :upstream/bytes (.droppedBytes delivery) + :adapter/events 0 :adapter/bytes 0} + :decoder/resets (:decoder-resets current)}))) + +(defn next! + "Reads one event, timeout, or terminal map; never uses nil for termination. + The optional nonnegative Duration includes waiting for another reader. + Sequence numbers order deliveries from this stream. Close wakes a reader. + Remote termination drains buffered bytes and reports trailing text in the + terminal outcome; intentional close discards buffered output." + ([stream] (next! stream nil)) + ([stream timeout] + (when-not (and (instance? OutputStream stream) (or (nil? timeout) (duration? timeout))) + (invalid! :control/next)) + (let [^OutputStream stream stream] + (perform! :control/next + #(let [^ReentrantLock lock (.-read-lock stream) + started (System/nanoTime) + budget (when timeout + (try (.toNanos ^Duration timeout) + (catch ArithmeticException _ Long/MAX_VALUE))) + acquired (if budget (.tryLock lock budget TimeUnit/NANOSECONDS) + (do (.lockInterruptibly lock) true))] + (if-not acquired + {:stream/status :timeout} + (try + (if-let [outcome (terminal stream)] + {:stream/status :terminal :stream/terminal outcome} + (let [^EventSubscription subscription (.-subscription stream) + ^Optional delivery (if budget + (.nextDelivery subscription + (Duration/ofNanos + (max 0 (- budget (- (System/nanoTime) started))))) + (.nextDelivery subscription))] + (if (.isPresent delivery) + (try (event-data stream (.get delivery)) + (catch CharacterCodingException failure + (.close subscription) + {:stream/status :terminal :stream/terminal (end! stream failure)})) + (if (.isClosed subscription) + {:stream/status :terminal :stream/terminal (finish! stream)} + {:stream/status :timeout})))) + (finally (.unlock lock))))))))) + +(defn close! + "Closes a connection or subscription. Closing a subscription leaves its + borrowed connection open; closing a connection affects all its subscribers." + [resource] + (when-not (or (instance? Connection resource) (instance? OutputStream resource)) + (invalid! :control/close)) + (perform! :control/close #(.close ^AutoCloseable resource))) diff --git a/libtmux-clojure/src/main/clojure/libtmux/core.clj b/libtmux-clojure/src/main/clojure/libtmux/core.clj new file mode 100644 index 00000000..9db6b145 --- /dev/null +++ b/libtmux-clojure/src/main/clojure/libtmux/core.clj @@ -0,0 +1,518 @@ +(ns libtmux.core + "Explicit tmux effects. Captured maps retain opaque Java references." + (:require [libtmux.data :as data]) + (:import [io.github.libtmux Server ServerEndpoint Session Window Pane Client + SessionSpec SessionSpec$Builder WindowSpec WindowSpec$Builder + SplitSpec SplitSpec$Builder CaptureSpec Dimensions Options Environment Hooks BufferInfo LibTmuxException + ObjectDoesNotExistException ServerNotRunningException + UnsupportedTmuxVersionException] + [io.github.libtmux.transport TmuxTimeoutException TmuxTransportException] + [io.github.libtmux.batch BatchResult OperationResult] + [java.nio.file Path] + [java.time Duration])) + +(def ^:dynamic ^:no-doc *execution-check* nil) + +(defn- invalid! [operation] + (throw (ex-info "Invalid tmux operation arguments" + {:tmux/error :tmux/validation :tmux/operation operation + :tmux/phase :validation :tmux/dispatch :not-dispatched}))) + +(defn- require! [operation valid?] + (when-not valid? (invalid! operation))) + +(defn- closed! [operation options schema] + (require! operation (and (map? options) + (every? #(contains? schema %) (keys options)) + (every? (fn [[k v]] ((get schema k) v)) options))) + options) + +(set! *warn-on-reflection* true) + +(defmacro ^:private call [object method classes & args] + (let [target (gensym "target")] + `(let [~target ~object] + (cond + ~@(mapcat (fn [class] + [`(instance? ~class ~target) + `(. ~(with-meta target {:tag class}) ~method ~@args)]) classes) + :else (throw (IllegalArgumentException. "Unexpected typed receiver")))))) + +(defn- ref! [operation value classes] + (require! operation (some #(instance? % value) classes)) + value) + +(def ^:private single-effect-operations + #{:raw :batch :chain :select :kill :link-window :unlink-window :move-window :join-pane + :resize :apply-layout :send-literal :send-keys :run-shell :detach-client + :switch-client :set-option :unset-option :set-environment + :unset-environment :remove-environment :set-hook :append-hook :unset-hook + :set-buffer :show-buffer :delete-buffer :paste-buffer :buffers + :signal-channel :capture :expand}) + +(defn- translate [operation mutation? cause & [composed?]] + (let [refused? (and (not composed?) (instance? UnsupportedTmuxVersionException cause)) + dispatch (cond + refused? :not-dispatched + (and (not composed?) (single-effect-operations operation) + (instance? TmuxTransportException cause)) + (case (.name (.outcome ^TmuxTransportException cause)) + "NOT_DISPATCHED" :not-dispatched + "COMPLETE" :complete + :unknown) + :else :unknown) + kind (cond + (instance? ObjectDoesNotExistException cause) :tmux/not-found + (instance? ServerNotRunningException cause) :tmux/server-unavailable + (instance? UnsupportedTmuxVersionException cause) :tmux/unsupported-version + (instance? TmuxTimeoutException cause) :tmux/timeout + (instance? TmuxTransportException cause) :tmux/transport + (and (instance? IllegalStateException cause) + (= "server is closed" (.getMessage ^Exception cause))) :tmux/closed + :else :tmux/failure)] + (ex-info "tmux operation failed" + {:tmux/error kind :tmux/operation operation + :tmux/phase (if refused? :validation :unknown) + :tmux/dispatch dispatch + :tmux/effects (if (and mutation? (not= :not-dispatched dispatch)) + :possible :none)} cause))) + +(defn- execute! [operation kind f] + (io! + (when *execution-check* (*execution-check* {:operation operation :kind kind})) + (try (f) + (catch LibTmuxException failure + (throw (translate operation (= kind :mutation) failure))) + (catch IllegalArgumentException failure + (if (and (#{:link-window :move-window :join-pane :switch-client} operation) + (#{"handles belong to different tmux servers" + "handles belong to different tmux server incarnations"} + (.getMessage failure))) + (throw (ex-info "References belong to different tmux servers" + {:tmux/error :tmux/validation :tmux/operation operation + :tmux/phase :validation :tmux/dispatch :not-dispatched + :tmux/effects :none} failure)) + (throw failure))) + (catch IllegalStateException failure + (if (= "server is closed" (.getMessage failure)) + (throw (translate operation (= kind :mutation) failure)) + (throw failure)))))) + +(defn error-summary + "Returns fixed error metadata without arguments or cause messages. + Printing the original exception and its cause chain is not sanitized." + [failure] + (select-keys (ex-data failure) + [:tmux/error :tmux/operation :tmux/phase :tmux/dispatch :tmux/effects])) + +(defn- path? [x] (or (string? x) (instance? Path x))) +(defn- path [x] (if (instance? Path x) x (Path/of ^String x (make-array String 0)))) +(defn- positive-int? [x] (and (integer? x) (< 0 x Integer/MAX_VALUE))) +(defn- nonnegative-int? [x] (and (integer? x) (<= 0 x Integer/MAX_VALUE))) +(defn- argv? [x] (and (vector? x) (seq x) (every? string? x))) +(defn- environment? [x] (and (map? x) (every? string? (keys x)) (every? string? (vals x)))) +(defn- duration? [x] (and (instance? Duration x) (not (.isNegative ^Duration x)) + (not (.isZero ^Duration x)))) + +(defn open! + "Opens local resources for an explicit :socket-path or :socket-name endpoint. + The returned Server supports with-open; close does not kill tmux." + [options] + (closed! :open options {:socket-path path? :socket-name string? :binary string? + :config-file path? :timeout duration?}) + (require! :open (= 1 (count (filter #(contains? options %) [:socket-path :socket-name])))) + (let [endpoint (try + (if (:socket-path options) + (ServerEndpoint/socketPath (path (:socket-path options))) + (ServerEndpoint/namedSocket (:socket-name options))) + (catch IllegalArgumentException _ (invalid! :open)))] + (execute! :open :read + #(let [builder (Server/builder)] + (.endpoint builder endpoint) + (when-let [x (:binary options)] (.binary builder x)) + (when-let [x (:config-file options)] (.configFile builder (path x))) + (when-let [x (:timeout options)] (.defaultTimeout builder x)) + (.build builder))))) + +(defn snapshot! "Acquires an eager captured graph from an explicit server." + [server] + (let [^Server server (ref! :snapshot server [Server])] + (execute! :snapshot :read #(data/from-capture (.capture server))))) + +(defn sessions! "Acquires captured sessions; acquisition failure throws." [server] + (:tmux/sessions (snapshot! server))) +(defn windows! "Acquires captured window occurrences." [server] + (:tmux/windows (snapshot! server))) +(defn panes! "Acquires captured pane occurrences." [server] + (:tmux/panes (snapshot! server))) +(defn clients! "Acquires captured clients." [server] + (:tmux/clients (snapshot! server))) + +(defn find-session! "Finds a session by exact name; absence returns nil." [server name] + (require! :find-session (string? name)) + (data/one-or-none (filter #(= name (:session/name %)) (sessions! server)))) + +(defn- captured-reference [snapshot reference context] + (let [entries (cond (instance? Server reference) [snapshot] + (instance? Session reference) (:tmux/sessions snapshot) + (instance? Window reference) (:tmux/windows snapshot) + (instance? Pane reference) (:tmux/panes snapshot) + (instance? Client reference) (:tmux/clients snapshot))] + (data/one-or-none + (filter #(and (= reference (:tmux/ref %)) + (if (instance? Pane reference) + (= (or context (.context (.window ^Pane reference))) + (.context (.window ^Pane (:tmux/ref %)))) true)) entries)))) + +(defn- refresh-reference! [reference & [context]] + (let [^Server server (if (instance? Server reference) reference (call reference server [Session Window Pane Client]))] + (captured-reference (data/from-capture (.capture server)) reference context))) + +(defn refresh! + "Reacquires an explicit reference, retaining window and pane placement. + Returns nil if that occurrence disappeared; server failure throws." + [entry] + (let [reference (ref! :refresh entry [Server Session Window Pane Client])] + (execute! :refresh :read #(refresh-reference! reference)))) + +(def ^:private creation-schema + {:name string? :command argv? :directory path? :environment environment?}) + +(defn- build-spec [operation options builder schema] + (closed! operation options schema) + (require! operation (not (and (:cells options) (:percent options)))) + (require! operation (not (and (:empty? options) (:command options)))) + (try + (when-let [x (:name options)] (call builder named [SessionSpec$Builder WindowSpec$Builder] x)) + (when-let [x (:window-name options)] (.firstWindowNamed ^SessionSpec$Builder builder x)) + (when-let [x (:command options)] + (call builder running [SessionSpec$Builder WindowSpec$Builder SplitSpec$Builder] + (into-array String (if (= 1 (count x)) + (into ["/bin/sh" "-c" "exec \"$@\"" "libtmux-clojure"] x) + x)))) + (when-let [x (:directory options)] + (call builder in [SessionSpec$Builder WindowSpec$Builder SplitSpec$Builder] (path x))) + (when-let [x (:environment options)] + (call builder environment [SessionSpec$Builder WindowSpec$Builder SplitSpec$Builder] x)) + (when-let [x (:index options)] (.atIndex ^WindowSpec$Builder builder (int x))) + (when (:detached? options) (call builder detached [WindowSpec$Builder SplitSpec$Builder])) + (when (:empty? options) (.empty ^SplitSpec$Builder builder)) + (when (:full-window? options) (.fullWindow ^SplitSpec$Builder builder)) + (when (:zoomed? options) (.zoomed ^SplitSpec$Builder builder)) + (when-let [x (:direction options)] + (case x + :below (.below ^SplitSpec$Builder builder) + :above (.above ^SplitSpec$Builder builder) + :right (.toRight ^SplitSpec$Builder builder) + :left (.toLeft ^SplitSpec$Builder builder))) + (when-let [x (:cells options)] (.cells ^SplitSpec$Builder builder (int x))) + (when-let [x (:percent options)] (.percent ^SplitSpec$Builder builder (int x))) + (call builder build [SessionSpec$Builder WindowSpec$Builder SplitSpec$Builder]) + (catch IllegalArgumentException cause + (throw (ex-info "Invalid tmux specification" + {:tmux/error :tmux/validation :tmux/operation operation + :tmux/phase :validation :tmux/dispatch :not-dispatched} cause))))) + +(defn- capture-after-mutation! [operation reference & [context]] + (try (refresh-reference! reference context) + (catch LibTmuxException failure (throw (translate operation true failure true))) + (catch clojure.lang.ExceptionInfo failure + (if (:tmux/error (ex-data failure)) + (throw (translate operation true failure true)) + (throw failure))))) + +(defn new-session! "Creates a detached session and returns fresh captured data." + [server options] + (let [^Server server (ref! :new-session server [Server]) + ^SessionSpec spec (build-spec :new-session options (SessionSpec/builder) + (assoc creation-schema :window-name string?))] + (execute! :new-session :mutation #(capture-after-mutation! :new-session (.newSession server spec))))) + +(defn new-window! "Creates a window and returns fresh captured data." [session options] + (let [^Session session (ref! :new-window session [Session]) + ^WindowSpec spec (build-spec :new-window options (WindowSpec/builder) + (assoc creation-schema :index nonnegative-int? :detached? boolean?))] + (execute! :new-window :mutation #(capture-after-mutation! :new-window (.newWindow session spec))))) + +(defn split-pane! "Splits a pane using argv, never implicit shell interpolation." [pane options] + (let [^Pane pane (ref! :split-pane pane [Pane]) + ^SplitSpec spec (build-spec :split-pane options (SplitSpec/builder) + (merge (dissoc creation-schema :name) + {:direction #{:below :above :right :left} + :cells positive-int? :percent #(and (integer? %) (<= 1 % 100)) + :detached? boolean? :empty? boolean? + :full-window? boolean? :zoomed? boolean?}))] + (execute! :split-pane :mutation #(capture-after-mutation! :split-pane (.split pane spec) (.context (.window pane)))))) + +(defn rename! "Renames a session or window and captures its current data." [value name] + (require! :rename (string? name)) + (let [reference (ref! :rename value [Session Window])] + (execute! :rename :mutation #(capture-after-mutation! :rename (call reference rename [Session Window] name))))) + +(defn select! "Selects the preserved window or pane occurrence." [value] + (let [reference (ref! :select value [Window Pane])] + (execute! :select :mutation #(call reference select [Window Pane])))) + +(defn kill! "Kills the explicitly supplied session, window, or pane." [value] + (let [reference (ref! :kill value [Session Window Pane])] + (execute! :kill :mutation #(call reference kill [Session Window Pane])))) + +(defn kill-server! "Kills the explicit tmux server; close only releases local resources." [server] + (let [^Server server (ref! :kill-server server [Server])] + (execute! :kill-server :mutation #(.killServer server)))) + +(defn close! "Releases the supplied Server's local resources without killing tmux." [server] + (let [^Server server (ref! :close server [Server])] + (execute! :close :read #(.close server)))) + +(defn link-window! "Links a window into a session." [window session] + (let [^Window window (ref! :link-window window [Window]) + ^Session session (ref! :link-window session [Session])] + (execute! :link-window :mutation #(.linkTo window session)))) +(defn unlink-window! "Unlinks the preserved window occurrence." [window] + (let [^Window window (ref! :unlink-window window [Window])] + (execute! :unlink-window :mutation #(.unlink window)))) +(defn move-window! "Moves a window to a session and explicit index." [window session index] + (require! :move-window (nonnegative-int? index)) + (let [^Window window (ref! :move-window window [Window]) + ^Session session (ref! :move-window session [Session])] + (execute! :move-window :mutation #(.moveTo window session (int index))))) +(defn join-pane! "Joins a pane to a window." [pane window] + (let [^Pane pane (ref! :join-pane pane [Pane]) ^Window window (ref! :join-pane window [Window])] + (execute! :join-pane :mutation #(.joinTo pane window)))) +(defn resize! "Resizes a window or pane to positive cell dimensions." [value width height] + (require! :resize (and (positive-int? width) (positive-int? height))) + (let [reference (ref! :resize value [Window Pane])] + (execute! :resize :mutation #(call reference resizeTo [Window Pane] (Dimensions. (int width) (int height)))))) +(defn apply-layout! "Applies an explicit tmux layout string." [window layout] + (require! :apply-layout (string? layout)) + (let [^Window window (ref! :apply-layout window [Window])] + (execute! :apply-layout :mutation #(.applyLayout window ^String layout)))) + +(defn send-literal! "Sends literal text; completion does not mean application readiness." [pane text] + (require! :send-literal (string? text)) + (let [^Pane pane (ref! :send-literal pane [Pane])] + (execute! :send-literal :mutation #(.sendLiteral pane [text])))) +(defn send-keys! "Sends an explicit vector of tmux key names." [pane keys] + (require! :send-keys (argv? keys)) + (let [^Pane pane (ref! :send-keys pane [Pane])] + (execute! :send-keys :mutation #(.sendKeys pane keys)))) + +(defn capture! + "Captures pane lines; :from :history includes scrollback." + ([pane] (capture! pane {})) + ([pane options] + (closed! :capture options {:from #(or (= :history %) (and (integer? %) (<= Integer/MIN_VALUE % Integer/MAX_VALUE))) + :to #(and (integer? %) (<= Integer/MIN_VALUE % Integer/MAX_VALUE)) + :join-wrapped? boolean? :escape-sequences? boolean?}) + (let [^Pane pane (ref! :capture pane [Pane]) builder (CaptureSpec/builder)] + (when-let [x (:from options)] (if (= :history x) (.fromStartOfHistory builder) (.from builder (int x)))) + (when-let [x (:to options)] (.to builder (int x))) + (when (:join-wrapped? options) (.joiningWrappedLines builder)) + (when (:escape-sequences? options) (.withEscapeSequences builder)) + (let [spec (.build builder)] (execute! :capture :read #(vec (.capture pane spec))))))) + +(defn expand! "Evaluates an explicit tmux format at the supplied scope." [value format] + (require! :expand (string? format)) + (let [reference (ref! :expand value [Server Session Window Pane])] + (execute! :expand :read #(call reference expand [Server Session Window Pane] format)))) + +(defn raw! "Runs explicit tmux argv and returns exit status and streams; no shell." [server argv] + (require! :raw (argv? argv)) + (let [^Server server (ref! :raw server [Server])] + (execute! :raw :mutation #(let [result (.cmd server ^java.util.List argv)] + {:exit-code (.exitCode result) :stdout (vec (.stdout result)) :stderr (vec (.stderr result))})))) + +(defn run-shell! "Runs an explicitly requested shell command on the server." [server command] + (require! :run-shell (string? command)) + (let [^Server server (ref! :run-shell server [Server])] + (execute! :run-shell :mutation #(.runShell server command)))) + +(defn await-text! "Waits within a positive Duration; returns a keyword describing the outcome." [pane text timeout] + (require! :await-text (and (string? text) (duration? timeout))) + (let [^Pane pane (ref! :await-text pane [Pane])] + (execute! :await-text :wait + #(case (.name (.awaitText pane text timeout)) + "APPEARED" :appeared + "PRESENT_AT_ENTRY" :present-at-entry + "TIMED_OUT" :timed-out + "SERVER_GONE" :server-gone)))) + +(defn detach-client! "Detaches the explicit client." [client] + (let [^Client client (ref! :detach-client client [Client])] + (execute! :detach-client :mutation #(.detach client)))) +(defn switch-client! "Switches an explicit client to a session." [client session] + (let [^Client client (ref! :switch-client client [Client]) ^Session session (ref! :switch-client session [Session])] + (execute! :switch-client :mutation #(.switchTo client session)))) + +(defn- ^Options options-reference [reference] + (call reference options [Server Session Window Pane])) +(defn- ^Environment environment-reference [reference] + (call reference environment [Server Session])) +(defn- ^Hooks hooks-reference [reference] + (call reference hooks [Server Session Window Pane])) + +(defn options! + "Reads :local options by default, or :effective inherited options." + ([value] (options! value :local)) + ([value scope] + (require! :options (#{:local :effective} scope)) + (let [reference (ref! :options value [Server Session Window Pane])] + (execute! :options :read + #(into {} (let [^Options options (call reference options [Server Session Window Pane])] + (if (= scope :local) (.all options) (.effective options)))))))) +(defn set-option! "Sets a literal option value at the explicit scope." [value name text] + (require! :set-option (and (string? name) (string? text))) + (let [reference (ref! :set-option value [Server Session Window Pane])] + (execute! :set-option :mutation #(.set (options-reference reference) ^String name ^String text)))) +(defn unset-option! "Removes a local option override." [value name] + (require! :unset-option (string? name)) + (let [reference (ref! :unset-option value [Server Session Window Pane])] + (execute! :unset-option :mutation #(.unset (options-reference reference) name)))) + +(defn environment! + "Returns values and explicitly removed names, preserving removal versus absence." + ([value] (environment! value :local)) + ([value scope] + (require! :environment (#{:local :effective} scope)) + (let [reference (ref! :environment value [Server Session])] + (execute! :environment :read + #(let [^Environment environment (call reference environment [Server Session])] + {:values (into {} (if (= scope :local) (.all environment) (.effective environment))) + :removed (set (.removed environment))}))))) +(defn set-environment! "Sets a literal environment value." [value name text] + (require! :set-environment (and (string? name) (string? text))) + (let [reference (ref! :set-environment value [Server Session])] + (execute! :set-environment :mutation #(.set (environment-reference reference) name text)))) +(defn unset-environment! "Removes the local environment entry, allowing inheritance." [value name] + (require! :unset-environment (string? name)) + (let [reference (ref! :unset-environment value [Server Session])] + (execute! :unset-environment :mutation #(.unset (environment-reference reference) name)))) +(defn remove-environment! "Marks an environment name removed for new child processes." [value name] + (require! :remove-environment (string? name)) + (let [reference (ref! :remove-environment value [Server Session])] + (execute! :remove-environment :mutation #(.remove (environment-reference reference) name)))) + +(defn hooks! "Returns hook names and their ordered command vectors." [value] + (let [reference (ref! :hooks value [Server Session Window Pane])] + (execute! :hooks :read #(into {} (map (fn [[k v]] [k (vec v)])) + (.all (hooks-reference reference)))))) +(defn set-hook! "Replaces a hook with one explicit tmux argv command." [value event argv] + (require! :set-hook (and (string? event) (argv? argv))) + (let [reference (ref! :set-hook value [Server Session Window Pane])] + (execute! :set-hook :mutation #(.set (hooks-reference reference) ^String event ^java.util.List argv)))) +(defn append-hook! "Appends one explicit tmux argv command to a hook." [value event argv] + (require! :append-hook (and (string? event) (argv? argv))) + (let [reference (ref! :append-hook value [Server Session Window Pane])] + (execute! :append-hook :mutation #(.append (hooks-reference reference) ^String event ^java.util.List argv)))) +(defn unset-hook! "Removes an explicit hook." [value event] + (require! :unset-hook (string? event)) + (let [reference (ref! :unset-hook value [Server Session Window Pane])] + (execute! :unset-hook :mutation #(.unset (hooks-reference reference) event)))) + +(defn set-buffer! "Stores literal buffer contents." [server name text] + (require! :set-buffer (and (string? name) (string? text))) + (let [^Server server (ref! :set-buffer server [Server])] + (execute! :set-buffer :mutation #(.set (.buffers server) name text)))) +(defn show-buffer! "Reads literal buffer contents." [server name] + (require! :show-buffer (string? name)) + (let [^Server server (ref! :show-buffer server [Server])] + (execute! :show-buffer :read #(.show (.buffers server) name)))) +(defn delete-buffer! "Deletes an explicit buffer." [server name] + (require! :delete-buffer (string? name)) + (let [^Server server (ref! :delete-buffer server [Server])] + (execute! :delete-buffer :mutation #(.delete (.buffers server) name)))) +(defn paste-buffer! "Pastes an explicit buffer into a pane." [pane name] + (require! :paste-buffer (string? name)) + (let [^Pane pane (ref! :paste-buffer pane [Pane])] + (execute! :paste-buffer :mutation #(.pasteBuffer pane name)))) + + +(defn buffers! "Lists captured buffer names and byte sizes." [server] + (let [^Server server (ref! :buffers server [Server])] + (execute! :buffers :read + #(mapv (fn [^BufferInfo buffer] + {:buffer/name (.name buffer) :buffer/size (.size buffer)}) + (.list (.buffers server)))))) + + +(defn await-channel! + "Waits on an explicit tmux channel while reserving capacity for its release. + Returns :signalled, :timed-out, or :server-gone; interruption propagates." + [server name timeout] + (require! :await-channel (and (string? name) (duration? timeout))) + (let [^Server server (ref! :await-channel server [Server])] + (execute! :await-channel :wait + #(case (.name (.awaitReservingCapacity (.channel server name) timeout)) + "SIGNALLED" :signalled + "TIMED_OUT" :timed-out + "SERVER_GONE" :server-gone)))) + +(defn signal-channel! "Signals an explicit tmux channel." [server name] + (require! :signal-channel (string? name)) + (let [^Server server (ref! :signal-channel server [Server])] + (execute! :signal-channel :mutation #(.signal (.channel server name))))) + + +(defn- operation-result [index ^OperationResult result] + {:operation/index index + :operation/argv (vec (.argv result)) + :operation/outcome (case (.name (.outcome result)) + "COMPLETE" :complete + "FAILED" :failed + "SKIPPED" :skipped + "UNKNOWN" :unknown) + :operation/stdout (vec (.stdout result)) + :operation/stderr (vec (.stderr result))}) + +(defn- group! [operation server operations options] + (closed! operation options {:kind #{:mutation :wait}}) + (require! operation (and (vector? operations) (every? argv? operations))) + (require! operation + (or (= :wait (:kind options)) + (not-any? (fn [argv] + (and (#{"wait-for" "wait"} (first argv)) + (not-any? #{"-S" "-U"} (rest argv)))) + operations))) + (let [^Server server (ref! operation server [Server]) + waiting? (= :wait (:kind options))] + (execute! + operation (if waiting? :wait :mutation) + #(try + (let [^BatchResult result + (if (= operation :batch) + (let [builder (if waiting? (.batchReservingCapacity server) (.batch server))] + (doseq [argv operations] (.add builder ^java.util.List argv)) + (.run builder)) + (let [builder (if waiting? (.chainReservingCapacity server) (.chain server))] + (doseq [argv operations] (.then builder ^java.util.List argv)) + (.run builder)))] + (mapv operation-result (range) (.operations result))) + (catch TmuxTransportException cause + (let [error (translate operation true cause) + outcome (if (= :not-dispatched (:tmux/dispatch (ex-data error))) + :skipped :unknown)] + (throw (ex-info "tmux command group failed" + (assoc (ex-data error) :tmux/results + (mapv (fn [index argv] + {:operation/index index :operation/argv argv + :operation/outcome outcome + :operation/stdout [] :operation/stderr []}) + (range) operations)) + cause)))))))) + +(defn batch! + "Runs independent argv commands using a fresh Java Batch and returns positions. + Supply explicit targets. Failed/skipped outcomes are data; transport failure + throws with :tmux/results. Use {:kind :wait} for groups needing a release, + including blocking shell commands. No rollback or retry safety is promised." + ([server operations] (batch! server operations {})) + ([server operations options] (group! :batch server operations options))) + +(defn chain! + "Runs dependent argv commands with tmux's implicit target between positions. + Uses a fresh Java CommandChain. Failed/skipped outcomes are data; transport + failure throws with :tmux/results. Use {:kind :wait} for groups needing a release, + including blocking shell commands. No rollback or retry safety is promised." + ([server operations] (chain! server operations {})) + ([server operations options] (group! :chain server operations options))) diff --git a/libtmux-clojure/src/main/clojure/libtmux/data.clj b/libtmux-clojure/src/main/clojure/libtmux/data.clj new file mode 100644 index 00000000..4419d723 --- /dev/null +++ b/libtmux-clojure/src/main/clojure/libtmux/data.clj @@ -0,0 +1,232 @@ +(ns libtmux.data + "Pure captured values, identity, EDN projection, and local selection." + (:import [io.github.libtmux CapturedServer Client Pane ServerIdentity Session Window] + [io.github.libtmux.query FilterExpr] + [io.github.libtmux.snapshot WindowContext] + [java.time Instant] + [java.util List UUID])) + +(set! *warn-on-reflection* true) + +(defn- cardinality-error [operation reason] + (ex-info "Selection cardinality does not match the requested result" + {:tmux/error :tmux/cardinality + :tmux/operation operation + :tmux/phase :selection + :tmux/dispatch :not-dispatched + :cardinality/reason reason})) + +(defn one-or-none + "Returns the only value, nil for none, or throws for multiple values. + Stops requesting matches once ambiguity is known. Finding zero or one may + scan all input; chunked producers may realize more. Nil and false are values." + [values] + (when-let [items (seq values)] + (when (next items) + (throw (cardinality-error :one-or-none :multiple))) + (first items))) + +(defn exactly-one + "Returns the only value or throws ex-info for zero or multiple values. + Stops requesting matches once ambiguity is known. Finding zero or one may + scan all input; chunked producers may realize more. Nil and false are values." + [values] + (let [items (seq values)] + (when-not items + (throw (cardinality-error :exactly-one :none))) + (when (next items) + (throw (cardinality-error :exactly-one :multiple))) + (first items))) + +(defn data + "Projects captured data to EDN, removing :tmux/ref at every map level. + Instants become ISO-8601 strings; sequences and Java lists become vectors. + Unsupported JVM objects throw rather than producing unreadable EDN." + [value] + (cond + (map? value) (into {} (comp (remove #(= :tmux/ref (key %))) + (map (fn [[k v]] [(data k) (data v)]))) value) + (set? value) (into #{} (map data) value) + (or (sequential? value) (instance? List value)) (mapv data value) + (instance? Instant value) (str value) + (or (nil? value) (string? value) (number? value) (boolean? value) + (keyword? value) (symbol? value) (char? value) (instance? UUID value)) value + :else (throw (ex-info "Value has no supported EDN projection" + {:tmux/error :tmux/invalid-data + :tmux/operation :data + :tmux/phase :projection + :tmux/dispatch :not-dispatched})))) + +(defn entity-key + "Returns a captured physical identity, qualified by endpoint and server PID. + PID reuse is not a stronger daemon-incarnation guarantee." + [entry] + (or (:tmux/identity entry) + (throw (ex-info "Value has no captured entity identity" + {:tmux/error :tmux/invalid-data + :tmux/operation :entity-key + :tmux/phase :selection + :tmux/dispatch :not-dispatched})))) + +(defn link-key + "Returns the physical identity and contextual window placement. + Only window and pane occurrences carry a link; missing context throws." + [entry] + (let [identity (entity-key entry)] + (if-let [link (:tmux/link entry)] + [identity link] + (throw (ex-info "Value has no captured window placement" + {:tmux/error :tmux/invalid-data + :tmux/operation :link-key + :tmux/phase :selection + :tmux/dispatch :not-dispatched}))))) + +(defn same-entity? + "Compares physical entity identities without acquiring current state." + [left right] + (= (entity-key left) (entity-key right))) + +(defn same-link? + "Compares occurrences, including the session, window ID, and index." + [left right] + (= (link-key left) (link-key right))) + +(defn predicate + "Adapts a Java FilterExpr to a predicate over captured entries. + Evaluates the preserved :tmux/ref, including Java relation semantics; + editing fields in an entry does not change the reference being queried." + [^FilterExpr expression] + (when-not (instance? FilterExpr expression) + (throw (IllegalArgumentException. "Expected a Java FilterExpr"))) + (fn [entry] + (if-let [reference (:tmux/ref entry)] + (.test expression reference) + (throw (ex-info "Detached data has no query reference" + {:tmux/error :tmux/invalid-data + :tmux/operation :predicate + :tmux/phase :selection + :tmux/dispatch :not-dispatched}))))) + +(defn matching + "Filters captured entries using a Java FilterExpr over their preserved refs. + One argument returns a transducer; two return a lazy sequence. Both are local + and preserve order and multiplicity. Detached maps have no query reference." + ([expression] (filter (predicate expression))) + ([expression entries] (filter (predicate expression) entries))) + +(defn windows + "Returns the captured window occurrences in a snapshot or session." + [value] + (or (:tmux/windows value) (:session/windows value) + (throw (IllegalArgumentException. "Expected a snapshot or session")))) + +(defn panes + "Returns captured pane occurrences in a snapshot, session, or window." + [value] + (or (:tmux/panes value) (:window/panes value) + (when-let [ws (:session/windows value)] + (into [] (mapcat :window/panes) ws)) + (throw (IllegalArgumentException. "Expected a snapshot, session, or window")))) + +(defn active-window + "Returns the captured active window of a session, or nil." + [session] + (one-or-none (filter :window/active? (windows session)))) + +(defn active-pane + "Returns the captured active pane of a window or session, or nil." + [value] + (if (contains? value :session/windows) + (some-> (active-window value) active-pane) + (one-or-none (filter :pane/active? (panes value))))) + +(defn- placement [^WindowContext context] + {:session/id (.value (.session context)) + :window/id (.value (.window context)) + :window/index (.value (.index context))}) + +(defn- physical [server kind id] + (assoc server :tmux/kind kind :tmux/id id)) + +(defn- pane-entry [server ^Pane pane] + (let [size (.size pane) + position (.position pane) + edges (.edges pane) + pid (.pid pane)] + {:tmux/ref pane + :tmux/identity (physical server :pane (.value (.id pane))) + :tmux/link (placement (.context (.window pane))) + :pane/id (.value (.id pane)) + :pane/index (.index pane) + :pane/active? (.active pane) + :pane/current-command (.currentCommand pane) + :pane/floating? (.orElse (.floating pane) nil) + :pane/width (.width size) + :pane/height (.height size) + :pane/left (.left position) + :pane/top (.top position) + :pane/title (.title pane) + :pane/current-path (.currentPathText pane) + :pane/pid (when (.isPresent pid) (.getAsLong pid)) + :pane/edges {:top? (.top edges) :bottom? (.bottom edges) + :left? (.left edges) :right? (.right edges)}})) + +(defn- window-entry [server by-link ^Window window] + (let [link (placement (.context window)) + size (.size window)] + {:tmux/ref window + :tmux/identity (physical server :window (.value (.id window))) + :tmux/link link + :window/id (.value (.id window)) + :window/index (.value (.index window)) + :window/name (.name window) + :window/active? (.active window) + :window/linked? (.linked window) + :window/width (.width size) + :window/height (.height size) + :window/layout (.value (.layout window)) + :window/panes (get by-link link [])})) + +(defn- session-entry [server by-session ^Session session] + {:tmux/ref session + :tmux/identity (physical server :session (.value (.id session))) + :session/id (.value (.id session)) + :session/name (.name session) + :session/attached? (.attached session) + :session/windows (get by-session (.value (.id session)) [])}) + +(defn- client-entry [server by-id ^Client client] + (let [^Session session (.orElse (.session client) nil) + entry (when session (get by-id (.value (.id session))))] + {:tmux/ref client + :tmux/identity (physical server :client (.name client)) + :client/name (.name client) + :client/session entry + :client/active-window (when entry (active-window entry)) + :client/active-pane (when entry (active-pane entry))})) + +(defn from-capture + "Converts an authenticated Java CapturedServer to eager persistent values. + Reads no live getters and issues no commands. Relations share these values; + window links and pane occurrences retain their captured order and placement." + [^CapturedServer capture] + (let [^ServerIdentity server-identity (.identity capture) + pid (.processId server-identity) + server {:tmux/realm (.realm server-identity) + :tmux/server (.server server-identity) + :tmux/pid (when (.isPresent pid) (.getAsLong pid))} + snapshot (.snapshot capture) + ps (mapv #(pane-entry server %) (.panes capture)) + by-link (group-by :tmux/link ps) + ws (mapv #(window-entry server by-link %) (.windows capture)) + by-session (group-by (comp :session/id :tmux/link) ws) + ss (mapv #(session-entry server by-session %) (.sessions capture)) + by-id (into {} (map (juxt :session/id identity)) ss)] + {:tmux/ref (.server capture) + :tmux/identity (physical server :server (.server server-identity)) + :tmux/captured-at (.capturedAt snapshot) + :tmux/version (some-> (.orElse (.serverVersion snapshot) nil) str) + :tmux/sessions ss + :tmux/windows ws + :tmux/panes ps + :tmux/clients (mapv #(client-entry server by-id %) (.clients capture))})) diff --git a/libtmux-clojure/src/test/clojure/libtmux/async_test.clj b/libtmux-clojure/src/test/clojure/libtmux/async_test.clj new file mode 100644 index 00000000..a9d3451e --- /dev/null +++ b/libtmux-clojure/src/test/clojure/libtmux/async_test.clj @@ -0,0 +1,390 @@ +(ns libtmux.async-test + (:require [clojure.test :refer [deftest is testing]] + [libtmux.async :as async] + [libtmux.core :as core]) + (:import [java.util.concurrent CompletableFuture CompletionStage CountDownLatch Future FutureTask + ThreadPoolExecutor TimeUnit])) + +(def ^:dynamic *submitted-value* nil) + +(defn- await-uninterruptibly [^CountDownLatch latch] + (loop [] + (when-not (try (.await latch) true + (catch InterruptedException _ false)) + (recur)))) + +(deftest runtime-requires-a-release-slot + (is (thrown? IllegalArgumentException (async/runtime! {:max-running 1})))) + +(deftest submission-conveys-bindings-and-stage-is-read-only + (with-open [^libtmux.async.AsyncRuntime runtime (async/runtime!)] + (let [task (binding [*submitted-value* :captured] + (async/submit! runtime (fn [] *submitted-value*))) + stage (async/completion-stage task)] + (is (= :captured @task)) + (is (thrown? UnsupportedOperationException + (.complete ^java.util.concurrent.CompletableFuture stage :changed)))))) + +(deftest timed-deref-does-not-cancel-work + (with-open [^libtmux.async.AsyncRuntime runtime (async/runtime!)] + (let [release (CountDownLatch. 1) + task (async/submit! runtime #(.await release))] + (is (= ::waiting (deref task 20 ::waiting))) + (is (false? (realized? task))) + (.countDown release) + (is (nil? (deref task 500 ::timeout)))))) + +(deftest queued-cancellation-is-not-dispatched + (with-open [^libtmux.async.AsyncRuntime runtime + (async/runtime! {:max-running 2 :queue-limit 2})] + (let [release (CountDownLatch. 1) + running (async/submit! runtime #(.await release)) + invoked (atom false) + queued (async/submit! runtime #(reset! invoked true))] + (async/cancel! queued) + (is (thrown-with-msg? clojure.lang.ExceptionInfo #"cancelled" @queued)) + (is (false? @invoked)) + (.countDown release) + (deref running 500 ::timeout)))) + +(deftest wait-tasks-do-not-block-an-ordinary-release + (with-open [^libtmux.async.AsyncRuntime runtime (async/runtime! {:max-running 2})] + (let [release (CountDownLatch. 1) + waiting (async/submit! runtime {:kind :wait} #(.await release)) + ordinary (async/submit! runtime #(.countDown release))] + (is (nil? (deref ordinary 500 ::timeout))) + (is (nil? (deref waiting 500 ::timeout)))))) + +(deftest deadline-interrupts-worker + (with-open [^libtmux.async.AsyncRuntime runtime (async/runtime!)] + (let [interrupted (promise) + block (CountDownLatch. 1) + task (async/submit! runtime {:timeout-ms 30} + #(try + (.await block) + (catch InterruptedException _ + (deliver interrupted true))))] + (is (thrown? clojure.lang.ExceptionInfo @task)) + (is (= true (deref interrupted 500 ::timeout)))))) + +(deftest close-rejects-new-work + (let [^libtmux.async.AsyncRuntime runtime (async/runtime!)] + (.close runtime) + (is (thrown? IllegalStateException (async/submit! runtime identity))))) + +(deftest running-cancellation-waits-for-worker-cleanup + (with-open [^libtmux.async.AsyncRuntime runtime (async/runtime!)] + (let [block (CountDownLatch. 1) + started (CountDownLatch. 1) + cleaning (CountDownLatch. 1) + cleaned (CountDownLatch. 1) + task (async/submit! runtime + #(try + (.countDown started) + (.await block) + (catch InterruptedException _ + (.countDown cleaning) + (.await cleaned))))] + (is (.await started 500 TimeUnit/MILLISECONDS)) + (async/cancel! task) + (is (.await cleaning 500 TimeUnit/MILLISECONDS)) + (is (= :cancelling (async/task-state task))) + (is (false? (realized? task))) + (.countDown cleaned) + (is (thrown? clojure.lang.ExceptionInfo @task))))) + +(deftest wait-operation-requires-wait-kind + (with-open [^libtmux.async.AsyncRuntime runtime (async/runtime!)] + (let [task (async/submit! runtime + #(core/*execution-check* {:kind :wait}))] + (is (thrown-with-msg? clojure.lang.ExceptionInfo + #"requires a :wait task" @task))))) + +(deftest admission-is-globally-bounded + (with-open [^libtmux.async.AsyncRuntime runtime + (async/runtime! {:max-running 2 :queue-limit 1})] + (let [release (CountDownLatch. 1) + tasks [(async/submit! runtime #(.await release)) + (async/submit! runtime {:kind :wait} #(.await release)) + (async/submit! runtime #(.await release))] + rejected (async/submit! runtime #(.await release))] + (is (= {:status :busy :kind :ordinary} rejected)) + (.countDown release) + (doseq [task tasks] (deref task 500 ::timeout))))) + +(deftest submission-is-rejected-inside-a-transaction + (with-open [^libtmux.async.AsyncRuntime runtime (async/runtime!)] + (is (thrown? IllegalStateException + (dosync (async/submit! runtime identity)))))) + +(deftest managed-continuation-uses-owned-callback-executor + (with-open [^libtmux.async.AsyncRuntime runtime (async/runtime!)] + (let [task (async/submit! runtime (constantly :ok)) + ^CompletionStage stage (async/then! runtime task + (fn [_] (.getName (Thread/currentThread))))] + (is (thrown? UnsupportedOperationException + (.complete ^CompletableFuture stage :changed))) + (is (.startsWith ^String (.get (.toCompletableFuture stage) 500 TimeUnit/MILLISECONDS) + "libtmux-callback-"))))) + +(deftest timeout-validation-precedes-dispatch + (with-open [^libtmux.async.AsyncRuntime runtime (async/runtime!)] + (doseq [timeout [0 -1 1.5 "1"]] + (is (thrown? IllegalArgumentException + (async/submit! runtime {:timeout-ms timeout} identity)))))) + +(deftest cancelled-queued-task-releases-admission + (with-open [^libtmux.async.AsyncRuntime runtime + (async/runtime! {:max-running 2 :queue-limit 1})] + (let [release (CountDownLatch. 1) + running (async/submit! runtime #(.await release)) + waiting (async/submit! runtime {:kind :wait} #(.await release)) + queued (async/submit! runtime identity)] + (is (true? (async/cancel! queued))) + (is (instance? libtmux.async.Task (async/submit! runtime identity))) + (.countDown release) + (deref running 500 ::timeout) + (deref waiting 500 ::timeout)))) + +(deftest wait-admission-preserves-an-ordinary-slot + (with-open [^libtmux.async.AsyncRuntime runtime + (async/runtime! {:max-running 2 :queue-limit 1})] + (let [release (CountDownLatch. 1) + waits [(async/submit! runtime {:kind :wait} #(.await release)) + (async/submit! runtime {:kind :wait} #(.await release))] + rejected-wait (async/submit! runtime {:kind :wait} identity) + ordinary (async/submit! runtime #(.countDown release))] + (is (= {:status :busy :kind :wait} rejected-wait)) + (is (nil? (deref ordinary 500 ::timeout))) + (doseq [task waits] (deref task 500 ::timeout))))) + +(deftest dequeued-cancellation-still-finalizes + (with-open [^libtmux.async.AsyncRuntime runtime + (async/runtime! {:max-running 2 :queue-limit 1})] + (let [release (CountDownLatch. 1) + running (async/submit! runtime #(.await release)) + queued (async/submit! runtime identity) + ^ThreadPoolExecutor pool (.-ordinary runtime) + ^Future future @(.-worker ^libtmux.async.Task queued)] + ;; Model the executor boundary after dequeue and before Callable.call. + (is (.remove pool ^Runnable future)) + (is (true? (async/cancel! queued))) + (let [outcome (try (deref queued 300 ::stuck) + (catch clojure.lang.ExceptionInfo error error))] + (is (instance? clojure.lang.ExceptionInfo outcome)) + (is (re-find #"cancelled" (.getMessage ^Throwable outcome)))) + (is (instance? libtmux.async.Task (async/submit! runtime identity))) + (.countDown release) + (deref running 500 ::timeout)))) + +(deftest first-cancellation-wins + (with-open [^libtmux.async.AsyncRuntime runtime (async/runtime!)] + (let [started (CountDownLatch. 1) + release (CountDownLatch. 1) + task (async/submit! runtime #(do (.countDown started) (.await release)))] + (is (.await started 500 TimeUnit/MILLISECONDS)) + (is (true? (async/cancel! task))) + (is (false? (#'async/request-cancel! task :timeout))) + (.countDown release) + (let [failure (try @task (catch clojure.lang.ExceptionInfo error error))] + (is (= :cancelled (:tmux/error (ex-data failure)))))))) + +(deftest pending-continuations-are-bounded-before-parent-completes + (with-open [^libtmux.async.AsyncRuntime runtime + (async/runtime! {:queue-limit 1})] + (let [release (CountDownLatch. 1) + task (async/submit! runtime #(.await release))] + (async/then! runtime task identity) + (async/then! runtime task identity) + (let [failure (try (async/then! runtime task identity) + (catch clojure.lang.ExceptionInfo error error))] + (is (= :busy (:tmux/error (ex-data failure)))) + (is (= :not-dispatched (:tmux/dispatch (ex-data failure))))) + (.countDown release) + (deref task 500 ::timeout)))) + +(deftest close-budget-includes-blocking-inline-stage-callback + (let [runtime (async/runtime! {:close-timeout-ms 40}) + release-task (CountDownLatch. 1) + release-callback (CountDownLatch. 1) + task (async/submit! runtime #(.await release-task)) + stage (.toCompletableFuture ^CompletionStage (async/completion-stage task))] + (.whenComplete stage + (reify java.util.function.BiConsumer + (accept [_ _ _] (.await release-callback)))) + (let [started (System/nanoTime) + failure (try (.close ^libtmux.async.AsyncRuntime runtime) + (catch clojure.lang.ExceptionInfo error error)) + elapsed-ms (.toMillis TimeUnit/NANOSECONDS (- (System/nanoTime) started))] + (is (= :cleanup-pending (:tmux/error (ex-data failure)))) + (is (< elapsed-ms 500))) + (.countDown release-task) + (.countDown release-callback))) + +(deftest late-worker-cleanup-remains-publishable-after-failed-close + (let [runtime (async/runtime! {:close-timeout-ms 40}) + started (CountDownLatch. 1) + release (CountDownLatch. 1) + task (async/submit! runtime + #(do + (.countDown started) + (await-uninterruptibly release)))] + (is (.await started 500 TimeUnit/MILLISECONDS)) + (let [failure (try (.close ^libtmux.async.AsyncRuntime runtime) + (catch clojure.lang.ExceptionInfo error error))] + (is (= :cleanup-pending (:tmux/error (ex-data failure)))) + (is (= :cancelling (async/task-state task))) + (is (false? (realized? task)))) + (.countDown release) + (let [outcome (try (deref task 500 ::stuck) + (catch clojure.lang.ExceptionInfo error error))] + (is (instance? clojure.lang.ExceptionInfo outcome)) + (is (= :cancelled (:tmux/error (ex-data outcome))))) + (is (nil? (.close ^libtmux.async.AsyncRuntime runtime))))) + +(deftest lifecycle-effects-are-rejected-inside-transactions + (let [runtime (async/runtime!) + started (CountDownLatch. 1) + release (CountDownLatch. 1) + task (async/submit! runtime #(do (.countDown started) (.await release)))] + (is (.await started 500 TimeUnit/MILLISECONDS)) + (is (thrown? IllegalStateException (dosync (async/cancel! task)))) + (is (= :running (async/task-state task))) + (is (thrown? IllegalStateException (dosync (async/then! runtime task identity)))) + (is (thrown? IllegalStateException (dosync (async/close-runtime! runtime)))) + (.countDown release) + (deref task 500 ::timeout) + (.close ^libtmux.async.AsyncRuntime runtime))) + +(deftest blocked-inline-completions-retain-bounded-publication-admission + (let [runtime (async/runtime! {:max-running 2 :queue-limit 1}) + release-callbacks (CountDownLatch. 1) + tasks (atom [])] + (try + (dotimes [_ 3] + (let [entered (CountDownLatch. 1) + release-task (CountDownLatch. 1) + task (async/submit! runtime #(.await release-task)) + stage (.toCompletableFuture ^CompletionStage (async/completion-stage task))] + (.whenComplete stage + (reify java.util.function.BiConsumer + (accept [_ _ _] + (.countDown entered) + (.await release-callbacks)))) + (swap! tasks conj task) + (.countDown release-task) + (is (.await entered 500 TimeUnit/MILLISECONDS)))) + (is (= {:status :busy :kind :ordinary} + (async/submit! runtime identity))) + (finally + (.countDown release-callbacks) + (doseq [task @tasks] (deref task 500 ::timeout)) + (.close ^libtmux.async.AsyncRuntime runtime))))) + +(deftest accepted-cancellation-wins-after-thunk-return + (with-open [^libtmux.async.AsyncRuntime runtime (async/runtime!)] + (let [entered-finalization (CountDownLatch. 1) + release-finalization (CountDownLatch. 1) + real-finish @#'async/finish!] + (with-redefs [async/finish! + (fn [task throwable value] + (.countDown entered-finalization) + (await-uninterruptibly release-finalization) + (real-finish task throwable value))] + (let [task (async/submit! runtime (constantly :success))] + (is (.await entered-finalization 500 TimeUnit/MILLISECONDS)) + (is (true? (async/cancel! task))) + (.countDown release-finalization) + (let [outcome (try (deref task 500 ::stuck) + (catch clojure.lang.ExceptionInfo error error))] + (is (= :cancelled (:tmux/error (ex-data outcome)))))))))) + +(deftest cancellation-retains-structured-partial-effect-failure + (let [transport (ex-info "group interrupted" + {:tmux/error :tmux/transport + :tmux/dispatch :not-dispatched + :tmux/results [{:index 0 :status :unknown}]}) + cancellation (#'async/failure :cancelled :unknown) + combined (#'async/reconcile-failure cancellation transport)] + (is (= :cancelled (:tmux/error (ex-data combined)))) + (is (= :unknown (:tmux/dispatch (ex-data combined)))) + (is (= [{:index 0 :status :unknown}] (:tmux/results (ex-data combined)))) + (is (identical? transport (.getCause ^Throwable combined))))) + +(deftest deadline-and-executor-rejection-share-one-finalizer + (with-open [^libtmux.async.AsyncRuntime runtime + (async/runtime! {:max-running 4 :queue-limit 1})] + (let [release (CountDownLatch. 1) + blockers (vec (repeatedly 4 #(async/submit! runtime + (fn [] (await-uninterruptibly release))))) + publishing (CountDownLatch. 1) + deadline-fired (CountDownLatch. 1) + allow-interrupt (CountDownLatch. 1) + result (atom nil) + real-reset reset! + real-interrupt @#'async/interrupt! + real-execute @#'async/execute!] + (with-redefs [async/execute! + (fn [executor task] + (if (instance? ThreadPoolExecutor executor) + (do + (.countDown publishing) + (await-uninterruptibly deadline-fired) + (.countDown allow-interrupt) + (throw (java.util.concurrent.RejectedExecutionException. "forced full pool"))) + (real-execute executor task))) + async/interrupt! + (fn [task] + (.countDown deadline-fired) + (await-uninterruptibly allow-interrupt) + (real-interrupt task))] + (let [submitter (.start (Thread/ofVirtual) + ^Runnable #(real-reset result + (async/submit! runtime {:timeout-ms 1} + identity)))] + (is (.await publishing 500 TimeUnit/MILLISECONDS)) + (is (.await deadline-fired 500 TimeUnit/MILLISECONDS)) + (.join submitter 500) + (is (not (.isAlive submitter))) + (is (= {:status :busy :kind :ordinary} @result)))) + (.countDown release) + (doseq [task blockers] (deref task 500 ::stuck)) + (is (= 5 (.availablePermits ^java.util.concurrent.Semaphore + (.-admission runtime))))))) + +(deftest close-never-succeeds-with-an-open-completion-executor + (let [^libtmux.async.AsyncRuntime runtime (async/runtime! {:close-timeout-ms 100}) + fake (CompletableFuture.) + callback-entered (CountDownLatch. 1) + release-callback (CountDownLatch. 1) + callback-removed (CountDownLatch. 1) + eligibility-checked (CountDownLatch. 1) + allow-close (CountDownLatch. 1) + close-result (atom nil) + real-check @#'async/shutdown-completion-if-idle!] + (swap! (.-callback-stages runtime) conj fake) + (.whenComplete fake + (reify java.util.function.BiConsumer + (accept [_ _ _] + (.countDown callback-entered) + (await-uninterruptibly release-callback) + (swap! (.-callback-stages runtime) disj fake) + (.countDown callback-removed)))) + (with-redefs [async/shutdown-completion-if-idle! + (fn [owner] + (real-check owner) + (.countDown eligibility-checked) + (await-uninterruptibly allow-close))] + (let [closer (.start (Thread/ofVirtual) + ^Runnable #(reset! close-result + (try (.close ^libtmux.async.AsyncRuntime runtime) + :closed + (catch clojure.lang.ExceptionInfo error error))))] + (is (.await callback-entered 500 TimeUnit/MILLISECONDS)) + (is (.await eligibility-checked 500 TimeUnit/MILLISECONDS)) + (.countDown release-callback) + (is (.await callback-removed 500 TimeUnit/MILLISECONDS)) + (.countDown allow-close) + (.join closer 500) + (is (= :cleanup-pending (:tmux/error (ex-data @close-result)))))) + (is (nil? (.close ^libtmux.async.AsyncRuntime runtime))))) diff --git a/libtmux-clojure/src/test/clojure/libtmux/control_test.clj b/libtmux-clojure/src/test/clojure/libtmux/control_test.clj new file mode 100644 index 00000000..f3e05b2d --- /dev/null +++ b/libtmux-clojure/src/test/clojure/libtmux/control_test.clj @@ -0,0 +1,211 @@ +(ns libtmux.control-test + (:require [clojure.test :refer [deftest is]] + [libtmux.control :as control]) + (:import [io.github.libtmux PaneId] + [io.github.libtmux.batch OperationOutcome] + [io.github.libtmux.control ControlReply EventSubscription + EventSubscription$EndReason EventSubscription$Termination + PaneOutputBytes PaneOutputDecoder] + [java.lang AutoCloseable] + [java.nio.charset CodingErrorAction] + [java.time Duration] + [java.util Optional] + [java.util.concurrent CountDownLatch TimeUnit] + [java.util.concurrent.locks ReentrantLock] + [java.util.function Consumer ToLongFunction])) + +(defn- test-stream [encoding malformed capacity max-bytes] + (let [constructor (.getDeclaredConstructor EventSubscription + (into-array Class [Integer/TYPE Long/TYPE + ToLongFunction Consumer])) + _ (.setAccessible constructor true) + subscription (.newInstance constructor + (object-array [(int capacity) (long max-bytes) + (reify ToLongFunction + (applyAsLong [_ event] + (.size ^PaneOutputBytes event))) + (reify Consumer (accept [_ _]))]))] + (libtmux.control.OutputStream. + subscription (when (= :utf-8 encoding) (PaneOutputDecoder. malformed)) + {:tmux/endpoint :test} {:max-panes 2} + (atom {:sequence 0 :panes #{} :decoder-resets 0 :loss 0}) (ReentrantLock.)))) + +(defn- offer! [^libtmux.control.OutputStream stream pane octets] + (let [method (.getDeclaredMethod EventSubscription "offer" (into-array Class [Object]))] + (.setAccessible method true) + (.invoke method (.-subscription stream) + (object-array [(PaneOutputBytes. (PaneId. pane) + (byte-array (map unchecked-byte octets)))])))) + +(defn- terminate! [^libtmux.control.OutputStream stream reason cause] + (let [method (.getDeclaredMethod EventSubscription "end" + (into-array Class [EventSubscription$Termination]))] + (.setAccessible method true) + (.invoke method (.-subscription stream) + (object-array [(EventSubscription$Termination. reason (Optional/ofNullable cause))])))) + +(deftest protocol-complete-does-not-claim-command-completion + (doseq [[status expected] [[OperationOutcome/COMPLETE :complete] + [OperationOutcome/FAILED :failed] + [OperationOutcome/UNKNOWN :unknown]]] + (is (= {:reply/status expected :reply/lines ["reply"] + :operation/completion :unverified} + (#'control/reply-data (ControlReply. status ["reply"])))))) + +(deftest invalid-input-does-not-contact-tmux + (doseq [action [#(control/attach! {}) #(control/send! nil ["list-panes"]) + #(control/subscribe! nil) #(control/next! nil) #(control/close! nil)]] + (let [failure (try (action) (catch clojure.lang.ExceptionInfo error error))] + (is (= :tmux/validation (:tmux/error (ex-data failure)))) + (is (= :not-dispatched (:tmux/dispatch (ex-data failure))))))) + +(deftest terminal-observers-see-the-complete-trailing-text-outcome + (with-open [^libtmux.control.OutputStream stream + (test-stream :utf-8 CodingErrorAction/REPLACE 2 8)] + (offer! stream "%1" [195]) + (is (= "" (:output/text (control/next! stream Duration/ZERO)))) + (terminate! stream EventSubscription$EndReason/CONTROL_EXIT nil) + (let [published (CountDownLatch. 1) + release (CountDownLatch. 1) + finished (CountDownLatch. 1) + result (atom nil) + state (.-state stream)] + (add-watch state ::publication + (fn [_ _ previous current] + (when (and (nil? (:terminal previous)) (:terminal current)) + (.countDown published) + (.await release)))) + (let [worker (.start (Thread/ofVirtual) + ^Runnable #(try (reset! result (control/next! stream Duration/ZERO)) + (finally (.countDown finished))))] + (try + (is (.await published 900 TimeUnit/MILLISECONDS)) + (let [observed (control/terminal stream)] + (is (= [{:pane/id "%1" :output/text "�"}] (:output/trailing observed))) + (.countDown release) + (is (.await finished 900 TimeUnit/MILLISECONDS)) + (is (identical? observed (:stream/terminal @result))) + (is (identical? observed (control/terminal stream)))) + (finally + (.countDown release) + (.join worker 900) + (remove-watch state ::publication))))))) + +(deftest upstream-gap-resets-incomplete-text-and-reports-the-reset + (with-open [^AutoCloseable stream (test-stream :utf-8 CodingErrorAction/REPLACE 1 8)] + (offer! stream "%1" [195]) + (is (= "" (:output/text (control/next! stream Duration/ZERO)))) + (offer! stream "%1" [88]) + (offer! stream "%1" [169]) + (let [event (control/next! stream Duration/ZERO)] + (is (= "�" (:output/text event))) + (is (= 1 (get-in event [:loss :upstream/events]))) + (is (= 1 (:decoder/resets event)))))) + +(deftest terminal-gap-reports-reset-without-inventing-trailing-text + (with-open [^AutoCloseable stream (test-stream :utf-8 CodingErrorAction/REPLACE 1 1)] + (offer! stream "%1" [195]) + (control/next! stream Duration/ZERO) + (offer! stream "%1" [65 66]) + (terminate! stream EventSubscription$EndReason/CONTROL_EXIT nil) + (let [outcome (:stream/terminal (control/next! stream Duration/ZERO))] + (is (= [] (:output/trailing outcome))) + (is (= 1 (:decoder/resets outcome))) + (is (= 1 (get-in outcome [:loss :upstream/events])))))) + +(deftest timed-read-includes-waiting-for-another-reader + (with-open [^libtmux.control.OutputStream stream + (test-stream :bytes CodingErrorAction/REPORT 2 8)] + (let [^ReentrantLock lock (.-read-lock stream) + finished (CountDownLatch. 1) + outcome (atom nil)] + (.lock lock) + (let [worker (.start (Thread/ofVirtual) + ^Runnable #(try (reset! outcome (control/next! stream Duration/ZERO)) + (finally (.countDown finished))))] + (try + (is (.await finished 900 TimeUnit/MILLISECONDS)) + (finally (.unlock lock) (.join worker 900))) + (is (= {:stream/status :timeout} @outcome)))))) + +(deftest closeable-stream-rejects-retriable-transaction-effects + (with-open [^AutoCloseable stream (test-stream :bytes CodingErrorAction/REPORT 2 8)] + (is (= :rejected (try (dosync (.close stream)) :closed + (catch IllegalStateException _ :rejected)))) + (is (= {:stream/status :timeout} (control/next! stream Duration/ZERO))))) + +(deftest bounded-stream-reports-event-byte-and-oversize-loss + (doseq [[capacity max-bytes chunks expected lost-events lost-bytes] + [[2 8 [[65] [66] [67]] [66 67] 1 1] + [8 2 [[65] [66] [67]] [66 67] 1 1] + [8 2 [[65] [66 67 68] [69]] [69] 2 4]]] + (with-open [^AutoCloseable stream + (test-stream :bytes CodingErrorAction/REPORT capacity max-bytes)] + (doseq [chunk chunks] (offer! stream "%1" chunk)) + (let [events (mapv (fn [_] (control/next! stream Duration/ZERO)) expected)] + (is (= expected (mapv #(bit-and 255 (first (:output/bytes %))) events))) + (is (every? #(= {:upstream/events lost-events :upstream/bytes lost-bytes + :adapter/events 0 :adapter/bytes 0} (:loss %)) events)) + (is (= {:stream/status :timeout} (control/next! stream Duration/ZERO))))))) + +(deftest remote-termination-drains-buffer-and-preserves-original-cause + (with-open [^AutoCloseable stream (test-stream :bytes CodingErrorAction/REPORT 2 8)] + (let [cause (java.io.IOException. "control lost")] + (offer! stream "%1" [65]) + (offer! stream "%1" [66]) + (terminate! stream EventSubscription$EndReason/TRANSPORT_FAILURE cause) + (is (nil? (control/terminal stream))) + (is (= [65 66] (mapv (fn [_] + (first (:output/bytes (control/next! stream Duration/ZERO)))) + (range 2)))) + (let [outcome (:stream/terminal (control/next! stream Duration/ZERO))] + (is (= :transport-failure (:reason outcome))) + (is (identical? cause (:cause outcome))) + (is (identical? outcome (:stream/terminal (control/next! stream Duration/ZERO)))))))) + +(deftest malformed-report-ends-with-a-stable-decode-cause + (doseq [octets [[255] [195]]] + (with-open [^AutoCloseable stream (test-stream :utf-8 CodingErrorAction/REPORT 2 8)] + (offer! stream "%1" octets) + (terminate! stream EventSubscription$EndReason/CONTROL_EXIT nil) + (let [first-result (control/next! stream Duration/ZERO) + result (if (= :event (:stream/status first-result)) + (control/next! stream Duration/ZERO) first-result) + outcome (:stream/terminal result)] + (is (= :decode-failure (:reason outcome))) + (is (instance? java.nio.charset.CharacterCodingException (:cause outcome))) + (is (identical? outcome (control/terminal stream))) + (is (identical? outcome (:stream/terminal (control/next! stream Duration/ZERO)))))))) + +(deftest competing-readers-deliver-each-event-once + (with-open [^AutoCloseable stream (test-stream :bytes CodingErrorAction/REPORT 16 16)] + (doseq [octet (range 16)] (offer! stream "%1" [octet])) + (terminate! stream EventSubscription$EndReason/CONTROL_EXIT nil) + (let [ready (CountDownLatch. 2) + release (CountDownLatch. 1) + finished (CountDownLatch. 2) + results (atom []) + read-events (fn [] + (.countDown ready) + (.await release) + (try + (loop [] + (let [result (control/next! stream)] + (swap! results conj result) + (when (= :event (:stream/status result)) (recur)))) + (finally (.countDown finished)))) + workers (mapv (fn [_] (.start (Thread/ofVirtual) ^Runnable read-events)) (range 2))] + (try + (is (.await ready 900 TimeUnit/MILLISECONDS)) + (.countDown release) + (is (.await finished 900 TimeUnit/MILLISECONDS)) + (let [events (sort-by :event/sequence (filter #(= :event (:stream/status %)) @results)) + outcomes (map :stream/terminal (filter #(= :terminal (:stream/status %)) @results))] + (is (= (range 1 17) (map :event/sequence events))) + (is (= (range 16) (map #(first (:output/bytes %)) events))) + (is (= 2 (count outcomes))) + (is (every? #(identical? (control/terminal stream) %) outcomes))) + (finally + (.countDown release) + (control/close! stream) + (doseq [^Thread worker workers] (.join worker 900))))))) diff --git a/libtmux-clojure/src/test/clojure/libtmux/core_test.clj b/libtmux-clojure/src/test/clojure/libtmux/core_test.clj new file mode 100644 index 00000000..ce0fee13 --- /dev/null +++ b/libtmux-clojure/src/test/clojure/libtmux/core_test.clj @@ -0,0 +1,133 @@ +(ns libtmux.core-test + (:require [clojure.test :refer [deftest is]] + [libtmux.core :as tmux]) + (:import [io.github.libtmux Server ServerConfig] + [io.github.libtmux.transport TmuxTransport CommandResult TmuxTransportException DispatchOutcome])) + +(defn- ^Server fake-server [dispatch] + (Server/using (.build (ServerConfig/builder)) + (reify TmuxTransport + (execute [_ request] (dispatch request)) + (close [_])))) + +(defn- failure [f] + (try (f) nil (catch clojure.lang.ExceptionInfo e e))) + +(deftest validation-rejects-before-dispatch + (let [calls (atom 0)] + (with-open [server (fake-server (fn [_] (swap! calls inc) (CommandResult. 0 [] [])))] + (doseq [options [{:unknown true} {:command "echo nope"} {:name 1} {:environment {:a "b"}}]] + (let [error (failure #(tmux/new-session! server options))] + (is (= :tmux/validation (:tmux/error (ex-data error)))) + (is (= :not-dispatched (:tmux/dispatch (ex-data error)))))) + (is (= :tmux/validation + (:tmux/error (ex-data (failure #(tmux/snapshot! {:tmux/ref server})))))) + (is (zero? @calls))) + (is (= :tmux/validation (:tmux/error (ex-data (failure #(tmux/open! {})))))) + (is (= :tmux/validation (:tmux/error (ex-data (failure #(tmux/kill! {:pane/id "%0"})))))))) + +(deftest stm-and-execution-hook-precede-dispatch + (let [calls (atom 0)] + (with-open [server (fake-server (fn [_] (swap! calls inc) (CommandResult. 0 [] [])))] + (is (thrown? IllegalStateException (dosync (tmux/raw! server ["list-sessions"])))) + (binding [tmux/*execution-check* (fn [metadata] + (is (= {:operation :raw :kind :mutation} metadata)) + (throw (ex-info "expired" {:expired true})))] + (is (:expired (ex-data (failure #(tmux/raw! server ["list-sessions"])))))) + (is (zero? @calls))))) + +(deftest original-cause-and-conservative-dispatch-survive + (let [cause (TmuxTransportException. "sensitive payload" DispatchOutcome/NOT_DISPATCHED nil)] + (with-open [server (fake-server (fn [_] (throw cause)))] + (let [error (failure #(tmux/raw! server ["set-buffer" "private"]))] + (is (identical? cause (.getCause ^Throwable error))) + (is (= {:tmux/error :tmux/transport :tmux/operation :raw + :tmux/phase :unknown :tmux/dispatch :not-dispatched :tmux/effects :none} + (tmux/error-summary error))) + (is (not (.contains (pr-str (tmux/error-summary error)) "sensitive"))))))) + +(deftest programmer-errors-and-interruption-are-not-reclassified + (doseq [cause [(IllegalArgumentException. "caller bug") + (InterruptedException. "cancelled")]] + (with-open [server (fake-server (fn [_] (throw cause)))] + (let [caught (try (tmux/raw! server ["list-sessions"]) + (catch Exception e e))] + (is (identical? cause caught)))))) + + +(deftest typed-single-effect-preserves-transport-certainty + (doseq [[outcome dispatch effects] [[DispatchOutcome/NOT_DISPATCHED :not-dispatched :none] + [DispatchOutcome/UNKNOWN :unknown :possible] + [DispatchOutcome/COMPLETE :complete :possible]]] + (let [cause (TmuxTransportException. "transport detail" outcome nil)] + (with-open [server (fake-server (fn [_] (throw cause)))] + (let [error (failure #(tmux/set-buffer! server "buffer" "contents"))] + (is (identical? cause (.getCause ^Throwable error))) + (is (= dispatch (:tmux/dispatch (ex-data error)))) + (is (= effects (:tmux/effects (ex-data error))))))))) + +(defn- run-group [kind server operations] + ((ns-resolve 'libtmux.core kind) server operations)) + +(defn- marked-reply [request exit] + (let [commands (.commands ^io.github.libtmux.transport.CommandRequest request)] + (CommandResult. exit + (vec (mapcat (fn [[index marker]] [(str "value-" index) (last marker)]) + (map-indexed vector (take-nth 2 (rest commands))))) + (if (zero? exit) [] ["late failure"])))) + +(deftest groups-use-fresh-builders-and-positioned-output + (doseq [kind ['batch! 'chain!]] + (let [sizes (atom [])] + (with-open [server (fake-server (fn [request] + (swap! sizes conj (count (.commands ^io.github.libtmux.transport.CommandRequest request))) + (marked-reply request 0)))] + (dotimes [_ 2] + (let [results (run-group kind server [["display-message" "-p" "one"] + ["display-message" "-p" "two"]])] + (is (= [0 1] (mapv :operation/index results))) + (is (= [:complete :complete] (mapv :operation/outcome results))) + (is (= [["value-0"] ["value-1"]] (mapv :operation/stdout results))))) + (is (= [4 4] @sizes)))))) + +(deftest groups-preserve-unknown-and-reject-invalid-input + (doseq [kind ['batch! 'chain!]] + (let [calls (atom 0)] + (with-open [server (fake-server (fn [request] (swap! calls inc) (marked-reply request 1)))] + (let [results (run-group kind server [["display-message" "-p" "one"] + ["display-message" "-p" "two"]])] + (is (= [:complete :unknown] (mapv :operation/outcome results)))) + (doseq [operations ['(["list-sessions"]) [["list-sessions"] []] [[1]]]] + (is (= :tmux/validation (:tmux/error (ex-data (failure #(run-group kind server operations))))))) + (is (= 1 @calls)) + (is (thrown? IllegalStateException (dosync (run-group kind server [["list-sessions"]])))) + (is (= 1 @calls)))))) + +(deftest group-transport-errors-preserve-all-unresolved-positions + (doseq [kind ['batch! 'chain!] + [outcome dispatch result-outcome] [[DispatchOutcome/NOT_DISPATCHED :not-dispatched :skipped] + [DispatchOutcome/UNKNOWN :unknown :unknown]]] + (let [cause (TmuxTransportException. "transport failed" outcome nil)] + (with-open [server (fake-server (fn [_] (throw cause)))] + (let [error (failure #(run-group kind server [["set-buffer" "a"] ["set-buffer" "b"]]))] + (is (identical? cause (.getCause ^Throwable error))) + (is (= dispatch (:tmux/dispatch (ex-data error)))) + (is (= [result-outcome result-outcome] + (mapv :operation/outcome (:tmux/results (ex-data error)))))))))) + +(deftest blocking-groups-require-explicit-wait-classification + (let [calls (atom 0)] + (with-open [server (fake-server (fn [request] (swap! calls inc) (marked-reply request 0)))] + (doseq [kind ['batch! 'chain!]] + (is (= :tmux/validation + (:tmux/error (ex-data (failure #(run-group kind server [["wait-for" "blocked"]])))))) + (is (= :tmux/validation + (:tmux/error (ex-data (failure #(run-group kind server [["wait-for" "-L" "lock"]]))))))) + (binding [tmux/*execution-check* (fn [metadata] + (is (= :wait (:kind metadata))) + (throw (ex-info "classified wait" {:classified true})))] + (doseq [kind ['batch! 'chain!]] + (is (:classified + (ex-data (failure #((ns-resolve 'libtmux.core kind) + server [["wait-for" "blocked"]] {:kind :wait}))))))) + (is (zero? @calls))))) diff --git a/libtmux-clojure/src/test/clojure/libtmux/data_test.clj b/libtmux-clojure/src/test/clojure/libtmux/data_test.clj new file mode 100644 index 00000000..7c1fdf6f --- /dev/null +++ b/libtmux-clojure/src/test/clojure/libtmux/data_test.clj @@ -0,0 +1,73 @@ +(ns libtmux.data-test + (:require [clojure.edn :as edn] + [clojure.test :refer [deftest is testing]] + [libtmux.data :as data]) + (:import [io.github.libtmux.query FilterExpr] + [java.time Instant])) + +(deftest cardinality-is-strict-and-bounded + (is (nil? (data/one-or-none []))) + (is (= false (data/exactly-one [false]))) + (is (nil? (data/exactly-one [nil]))) + (is (= :item (data/one-or-none [:item]))) + (doseq [[f values reason] [[data/exactly-one [] :none] + [data/exactly-one [1 2] :multiple] + [data/one-or-none [1 2] :multiple]]] + (let [error (try (f values) (catch clojure.lang.ExceptionInfo e e))] + (is (= :tmux/cardinality (:tmux/error (ex-data error)))) + (is (= reason (:cardinality/reason (ex-data error)))))) + (let [observed (atom 0) + items ((fn step [n] + (lazy-seq + (swap! observed inc) + (cons n (step (inc n))))) 0)] + (is (thrown? clojure.lang.ExceptionInfo (data/one-or-none items))) + (is (= 2 @observed)))) + +(deftest projection-is-edn-and-drops-references-recursively + (let [at (Instant/parse "2026-09-20T00:00:00Z") + value {:tmux/ref (Object.) + :tmux/captured-at at + :pane/floating? nil + :pane/active? false + :pane/current-path "raw\\xFF" + :children [{:tmux/ref (Object.) :pane/id "%0"}] + :tags #{:a :b}} + projected (data/data value)] + (is (= projected (edn/read-string (pr-str projected)))) + (is (= "2026-09-20T00:00:00Z" (:tmux/captured-at projected))) + (is (false? (:pane/active? projected))) + (is (contains? projected :pane/floating?)) + (is (not (contains? projected :pane/dead?))) + (is (= "raw\\xFF" (:pane/current-path projected))) + (is (= [{:pane/id "%0"}] (:children projected))) + (is (not (contains? projected :tmux/ref)))) + (is (thrown? clojure.lang.ExceptionInfo (data/data (Object.))))) + +(deftest physical-and-occurrence-identity-differ + (let [entity {:tmux/realm "local" :tmux/server "opaque" :tmux/pid 12 + :tmux/kind :pane :tmux/id "%0"} + first-link {:session/id "$0" :window/id "@0" :window/index 0} + second-link (assoc first-link :window/index 1) + a {:tmux/identity entity :tmux/link first-link} + b {:tmux/identity entity :tmux/link second-link}] + (is (data/same-entity? a b)) + (is (not (data/same-link? a b))) + (is (data/same-link? a (assoc a :pane/title "renamed"))) + (is (not (data/same-entity? a (assoc-in b [:tmux/identity :tmux/pid] 13)))) + (is (not (data/same-entity? a (assoc-in b [:tmux/identity :tmux/server] "other")))) + (is (thrown? clojure.lang.ExceptionInfo (data/entity-key {}))) + (is (thrown? clojure.lang.ExceptionInfo (data/link-key {:tmux/identity entity}))))) + +(deftest java-expression-adapters-follow-native-filter-arities + (let [expression (FilterExpr/and []) + a {:tmux/ref (Object.) :label :first} + b {:tmux/ref (Object.) :label :second} + entries [a b a] + predicate (data/predicate expression)] + (is (ifn? predicate)) + (is (true? (predicate a))) + (is (thrown? clojure.lang.ExceptionInfo (predicate (data/data a)))) + (is (= entries (into [] (data/matching expression) entries))) + (is (= entries (vec (data/matching expression entries)))) + (is (= [] (into [] (data/matching (FilterExpr/or [])) entries))))) diff --git a/libtmux-clojure/src/test/clojure/libtmux/internal/package_test.clj b/libtmux-clojure/src/test/clojure/libtmux/internal/package_test.clj new file mode 100644 index 00000000..471ba422 --- /dev/null +++ b/libtmux-clojure/src/test/clojure/libtmux/internal/package_test.clj @@ -0,0 +1,55 @@ +(ns libtmux.internal.package-test + (:require [clojure.java.io :as io] + [clojure.string :as str] + [clojure.test :as test :refer [deftest is]] + [libtmux.internal.test-runner :as runner]) + (:import [clojure.lang Compiler DynamicClassLoader] + [java.io File StringWriter] + [java.nio.file Files])) + +(deftest core-does-not-pull-the-optional-adapter + (is (nil? (io/resource "clojure/core/async.clj")))) + +(defn- with-test-sources [body] + (let [directory (.toFile (Files/createTempDirectory "clj-runner-" + (make-array java.nio.file.attribute.FileAttribute 0))) + property "libtmux.clojure.test-namespaces" + previous (System/getProperty property)] + (with-open [loader (DynamicClassLoader. (clojure.lang.RT/baseLoader))] + (.addURL loader (.toURL (.toURI directory))) + (try + (doseq [[namespace test-form] + {"runner-probe.visible-test" "(deftest pass (is true))" + "runner-probe.omitted-test" "(deftest fail (is false))"}] + (let [^File source (io/file directory + (str (str/replace (str/replace namespace "." "/") "-" "_") ".clj"))] + (.mkdirs (.getParentFile source)) + (spit source (str "(ns " namespace " (:require [clojure.test :refer [deftest is]]))\n" + test-form)))) + (System/setProperty property "runner-probe.visible-test") + (with-bindings {Compiler/LOADER loader} + (body directory)) + (finally + (if previous (System/setProperty property previous) (System/clearProperty property)) + (doseq [^File file (reverse (file-seq directory))] + (Files/deleteIfExists (.toPath file)))))))) + +(defn- runner-failure [& arguments] + (binding [test/*test-out* (StringWriter.)] + (try + (apply runner/-main arguments) + nil + (catch clojure.lang.ExceptionInfo failure failure)))) + +(deftest source-discovery-does-not-skip-an-omitted-failing-namespace + (with-test-sources + (fn [directory] + (is (= {:fail 1 :error 0} + (select-keys (ex-data (runner-failure "test" (str directory))) [:fail :error])))))) + +(deftest source-discovery-rejects-an-empty-root + (with-test-sources + (fn [directory] + (let [^File empty-root (io/file directory "empty")] + (.mkdir empty-root) + (is (some? (runner-failure "test" (str empty-root)))))))) diff --git a/libtmux/src/main/java/io/github/libtmux/CapturedServer.java b/libtmux/src/main/java/io/github/libtmux/CapturedServer.java new file mode 100644 index 00000000..a0dfdc64 --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/CapturedServer.java @@ -0,0 +1,68 @@ +package io.github.libtmux; + +import io.github.libtmux.snapshot.ServerSnapshot; +import java.util.List; + +/** + * Retains metadata and handles from one authenticated server capture. + * + *

Lists are immutable and preserve tmux order, including window links and pane occurrences. + * Accessors and captured handle relations issue no commands and remain readable after the server + * closes. Explicit handle operations still use the owning server and its PID fence. + * + *

The capture is an observed consistent graph, not a tmux transaction. Its identity carries + * the observed daemon PID, not a generation that distinguishes PID reuse. + */ +public final class CapturedServer { + + private final Server server; + private final ServerSnapshot snapshot; + + CapturedServer(Server server, ServerSnapshot snapshot) { + this.server = server; + this.snapshot = snapshot; + } + + /** Returns the server that acquired this capture. */ + public Server server() { + return server; + } + + /** Returns the identity bound to the captured daemon PID without probing tmux. */ + public ServerIdentity identity() { + return server.identity(snapshot); + } + + /** Returns the immutable snapshot that supplies every handle in this view. */ + public ServerSnapshot snapshot() { + return snapshot; + } + + /** Returns captured sessions in tmux order. */ + public List sessions() { + return snapshot.sessions().stream() + .map(state -> new Session(server, snapshot, state)) + .toList(); + } + + /** Returns captured window links in tmux order, retaining each placement. */ + public List windows() { + return snapshot.windows().stream() + .map(state -> new Window(server, snapshot, state)) + .toList(); + } + + /** Returns captured pane occurrences in tmux order, retaining each window placement. */ + public List panes() { + return snapshot.panes().stream() + .map(state -> new Pane(server, snapshot, state)) + .toList(); + } + + /** Returns captured clients in tmux order. */ + public List clients() { + return snapshot.clients().stream() + .map(state -> new Client(server, snapshot, state)) + .toList(); + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index 6ecd2e0d..820d8b4e 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -313,6 +313,16 @@ public Batch batch() { return new Batch(commands -> transport.execute(request(commands, config.defaultTimeout(), ""))); } + /** + * Collects a group that may wait for a release through this server's transport. + * + *

The transport reserves admission capacity for ordinary release commands. Encoding and + * positional attribution match {@link #batch()}. + */ + public Batch batchReservingCapacity() { + return new Batch(commands -> transport.executeWaiting(request(commands, config.defaultTimeout(), ""))); + } + /** * Starts a chain of commands where each one acts on what the last one made. * @@ -323,6 +333,11 @@ public CommandChain chain() { return new CommandChain(batch(), this::versionForCreation); } + /** Starts an implicit-target chain while reserving transport capacity for its release. */ + public CommandChain chainReservingCapacity() { + return new CommandChain(batchReservingCapacity(), this::versionForCreation); + } + /** * 3.2a answers "unknown command" for both of the prompt-history commands; {@code * cmd-show-prompt-history.c} lands in tmux itself at tag 3.3, under CHANGES' "3.2a TO 3.3" @@ -901,6 +916,19 @@ public ServerSnapshot snapshot() { } } + /** + * Captures metadata and entity handles from one observed hierarchy. + * + *

Accessing the returned view or its captured relations issues no commands. Acquisition + * uses the same consistency checks and replacement retry as {@link #snapshot()}. + * + * @throws ServerNotRunningException if no daemon is running + * @throws LibTmuxException if the capture otherwise fails + */ + public CapturedServer capture() { + return new CapturedServer(this, snapshot()); + } + /** * Every session, captured now. * 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 cf60488b..b9f8375a 100644 --- a/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java +++ b/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java @@ -20,9 +20,12 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.ToLongFunction; +import org.jspecify.annotations.Nullable; /** * A tmux client that stays attached and answers one command at a time. @@ -68,6 +71,8 @@ public final class ControlClient implements AutoCloseable { private final Thread reader; private final Thread errorReader; private final ControlProtocol protocol = new ControlProtocol(); + private final List> byteSubscriptions = new CopyOnWriteArrayList<>(); + private EventSubscription.@Nullable Termination termination; private final List> outputSubscriptions = new CopyOnWriteArrayList<>(); private final List> eventSubscriptions = new CopyOnWriteArrayList<>(); private final AtomicBoolean closed = new AtomicBoolean(); @@ -238,7 +243,10 @@ public boolean isAlive() { } /** - * Subscribes to terminal output tmux pushes. + * Subscribes to terminal output tmux pushes, retaining at most 16 MiB of UTF-8 text. + * + *

This compatibility API decodes each chunk independently. Use {@link #subscribeOutputBytes} + * and {@link PaneOutputDecoder} for characters split across chunks. * * @param capacity how many values this subscriber can retain before its oldest value is dropped * @return a pull subscription owned by the caller @@ -246,11 +254,15 @@ public boolean isAlive() { * @throws IllegalStateException if the client has ended */ public EventSubscription subscribeOutput(int capacity) { - return subscribe(outputSubscriptions, capacity); + return subscribe( + outputSubscriptions, + capacity, + ControlProtocol.DEFAULT_MAX_REPLY_BYTES, + value -> value.data().getBytes(StandardCharsets.UTF_8).length); } /** - * Subscribes to state changes tmux volunteers. + * Subscribes to state changes tmux volunteers, retaining at most 16 MiB of UTF-8 fields. * * @param capacity how many values this subscriber can retain before its oldest value is dropped * @return a pull subscription owned by the caller @@ -258,7 +270,23 @@ public EventSubscription subscribeOutput(int capacity) { * @throws IllegalStateException if the client has ended */ public EventSubscription subscribeEvents(int capacity) { - return subscribe(eventSubscriptions, capacity); + return subscribeEvents(capacity, ControlProtocol.DEFAULT_MAX_REPLY_BYTES); + } + + /** Subscribes to original pane bytes with independent event and payload byte limits. */ + public EventSubscription subscribeOutputBytes(int capacity, long maxBytes) { + return subscribe(byteSubscriptions, capacity, maxBytes, PaneOutputBytes::size); + } + + /** Subscribes to state changes, bounding retained UTF-8 field bytes as well as event count. */ + public EventSubscription subscribeEvents(int capacity, long maxBytes) { + return subscribe(eventSubscriptions, capacity, maxBytes, event -> { + long bytes = event.kind().getBytes(StandardCharsets.UTF_8).length; + for (String field : event.fields()) { + bytes += field.getBytes(StandardCharsets.UTF_8).length; + } + return bytes + event.value().orElse("").getBytes(StandardCharsets.UTF_8).length; + }); } /** @@ -302,8 +330,8 @@ public void close() { boolean closeOwner = closed.compareAndSet(false, true); if (closeOwner) { processTree.captureDescendants(); + closeSubscriptions(new EventSubscription.Termination(EventSubscription.EndReason.CLOSED, Optional.empty())); writer.close(); - closeSubscriptions(); } boolean reclaimed = processTree.terminate(); if (!closeOwner) { @@ -355,12 +383,18 @@ private void read() { if (result instanceof ControlProtocol.Reply reply) { complete(reply.outcome(), reply.lines()); } else if (result instanceof ControlProtocol.Notification notification) { - handleNotification(notification.line()); + handleNotification(notification.line(), line.bytes()); } } - } catch (IOException | ControlProtocol.LimitExceeded e) { - // The client ended. Everything still waiting is resolved below. + } catch (ControlProtocol.LimitExceeded | ControlLineReader.LimitExceeded | IllegalArgumentException e) { + closeSubscriptions( + new EventSubscription.Termination(EventSubscription.EndReason.PROTOCOL_FAILURE, Optional.of(e))); + } catch (IOException e) { + closeSubscriptions( + new EventSubscription.Termination(EventSubscription.EndReason.TRANSPORT_FAILURE, Optional.of(e))); } finally { + closeSubscriptions( + new EventSubscription.Termination(EventSubscription.EndReason.UNKNOWN, Optional.empty())); writer.readerEnded(); } } @@ -373,13 +407,18 @@ private void drainErrors() { } } - private void handleNotification(String line) { + private void handleNotification(String line, byte[] bytes) { if (line.startsWith("%output ")) { + offer(byteSubscriptions, PaneOutputBytes.parse(bytes)); publish(line); } else if (line.startsWith("%")) { // Everything else tmux volunteers about its own state. A snapshot is still how state // is read; this only says when reading it again would be worth the trouble. ControlEvent.parse(line).ifPresent(this::announce); + if (line.equals("%exit") || line.startsWith("%exit ")) { + closeSubscriptions(new EventSubscription.Termination( + EventSubscription.EndReason.CONTROL_EXIT, Optional.of(new IOException(line)))); + } } } @@ -389,7 +428,8 @@ private void complete(OperationOutcome outcome, List block) { private void terminate(TmuxTransportException failure) { failed = true; - closeSubscriptions(); + closeSubscriptions( + new EventSubscription.Termination(EventSubscription.EndReason.TRANSPORT_FAILURE, Optional.of(failure))); if (!processTree.terminate()) { failure.addSuppressed(new IllegalStateException("control process tree was not reclaimed")); } @@ -405,11 +445,12 @@ private void publish(String line) { offer(outputSubscriptions, output); } - private EventSubscription subscribe(List> subscriptions, int capacity) { + private EventSubscription subscribe( + List> subscriptions, int capacity, long maxBytes, ToLongFunction size) { if (subscriptionsClosed) { throw new IllegalStateException("control client has ended"); } - EventSubscription subscription = new EventSubscription<>(capacity, subscriptions::remove); + EventSubscription subscription = new EventSubscription<>(capacity, maxBytes, size, subscriptions::remove); subscriptions.add(subscription); if (subscriptionsClosed) { subscription.close(); @@ -424,13 +465,18 @@ private static void offer(List> subscriptions, T value) } } - private void closeSubscriptions() { + private synchronized void closeSubscriptions(EventSubscription.Termination reason) { + EventSubscription.Termination first = termination == null ? reason : termination; + termination = first; subscriptionsClosed = true; + for (EventSubscription subscription : byteSubscriptions) { + subscription.end(first); + } for (EventSubscription subscription : outputSubscriptions) { - subscription.close(); + subscription.end(first); } for (EventSubscription subscription : eventSubscriptions) { - subscription.close(); + subscription.end(first); } } diff --git a/libtmux/src/main/java/io/github/libtmux/control/ControlLineReader.java b/libtmux/src/main/java/io/github/libtmux/control/ControlLineReader.java index bffa49e1..21924afd 100644 --- a/libtmux/src/main/java/io/github/libtmux/control/ControlLineReader.java +++ b/libtmux/src/main/java/io/github/libtmux/control/ControlLineReader.java @@ -23,7 +23,29 @@ final class ControlLineReader implements Closeable { this.maxBytes = maxBytes; } - record Line(String text, int encodedBytes) {} + static final class Line { + private final String text; + private final int encodedBytes; + private final byte[] bytes; + + Line(String text, int encodedBytes, byte[] bytes) { + this.text = text; + this.encodedBytes = encodedBytes; + this.bytes = bytes.clone(); + } + + String text() { + return text; + } + + int encodedBytes() { + return encodedBytes; + } + + byte[] bytes() { + return bytes.clone(); + } + } @Nullable Line readLine() throws IOException { @@ -37,7 +59,7 @@ Line readLine() throws IOException { return line(bytes); } if (bytes.size() == maxBytes) { - throw new IOException("control line exceeded the " + maxBytes + " byte limit"); + throw new LimitExceeded(maxBytes); } bytes.write(next); } @@ -49,7 +71,18 @@ private static Line line(ByteArrayOutputStream bytes) { if (textLength > 0 && encoded[textLength - 1] == '\r') { textLength--; } - return new Line(Utf8.backslashReplace(Arrays.copyOf(encoded, textLength)), encoded.length); + return new Line( + Utf8.backslashReplace(Arrays.copyOf(encoded, textLength)), + encoded.length, + Arrays.copyOf(encoded, textLength)); + } + + static final class LimitExceeded extends IOException { + private static final long serialVersionUID = 1L; + + LimitExceeded(int limit) { + super("control line exceeded the " + limit + " byte limit"); + } } @Override diff --git a/libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java b/libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java index c4b6d5ce..dcde6e2a 100644 --- a/libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java +++ b/libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java @@ -6,6 +6,8 @@ import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; +import java.util.function.ToLongFunction; +import org.jspecify.annotations.Nullable; /** * Pulls events volunteered by one control client. @@ -13,18 +15,41 @@ *

Each subscription owns a fixed-capacity buffer. When that buffer is full, the next event * replaces its oldest event and increments {@link #droppedCount()}; another subscription has its * own buffer and loss count. The control protocol reader only offers values to these buffers. It - * never runs subscriber code. Public operations are thread-safe; concurrent readers compete for + * never runs subscriber code. Byte-limited subscriptions also discard oldest events until the + * next payload fits. A payload larger than the entire byte limit clears the buffer and is dropped. + * Use {@link #nextDelivery()} to associate loss with a returned event atomically. + * Public operations are thread-safe; concurrent readers compete for * the same sequence, and each event is returned at most once. * - *

Closing is terminal: it discards buffered events, wakes threads blocked in {@link #next()}, - * and makes every later read return empty. Events discarded by close are not overflow and do not - * increment the loss count. + *

Intentional close discards buffered events and wakes waiting readers. Remote termination + * preserves buffered events for draining before reads return empty. Both are terminal: no further + * events are accepted. Events discarded by intentional close do not increment overflow counts. * * @param the event type */ public final class EventSubscription implements AutoCloseable { + /** Describes why a subscription ended; UNKNOWN does not imply a server failure. */ + public enum EndReason { + CLOSED, + CONTROL_EXIT, + TRANSPORT_FAILURE, + PROTOCOL_FAILURE, + UNKNOWN + } + + /** Preserves the first observed terminal reason and its original exception, if known. */ + public record Termination(EndReason reason, Optional cause) {} + + /** Associates an event with the loss counters at removal from the buffer. */ + public record Delivery(T event, long droppedCount, long droppedBytes) {} + private final int capacity; + private final long maxBytes; + private final ToLongFunction size; + private long retainedBytes; + private long droppedBytes; + private @Nullable Termination termination; private final ArrayDeque events = new ArrayDeque<>(); private final ReentrantLock lock = new ReentrantLock(); private final Condition available = lock.newCondition(); @@ -33,6 +58,15 @@ public final class EventSubscription implements AutoCloseable { private boolean closed; EventSubscription(int capacity, Consumer> onClose) { + this(capacity, Long.MAX_VALUE, ignored -> 0, onClose); + } + + EventSubscription(int capacity, long maxBytes, ToLongFunction size, Consumer> onClose) { + if (maxBytes <= 0) { + throw new IllegalArgumentException("maxBytes must be positive"); + } + this.maxBytes = maxBytes; + this.size = size; if (capacity <= 0) { throw new IllegalArgumentException("capacity must be positive"); } @@ -46,11 +80,23 @@ void offer(T event) { if (closed) { return; } - if (events.size() == capacity) { - events.removeFirst(); + long bytes = size.applyAsLong(event); + if (bytes < 0) { + throw new IllegalArgumentException("event size is negative"); + } + while (!events.isEmpty() && (events.size() == capacity || bytes > maxBytes - retainedBytes)) { + long removed = size.applyAsLong(events.removeFirst()); + retainedBytes -= removed; + droppedBytes += removed; dropped++; } + if (bytes > maxBytes) { + dropped++; + droppedBytes += bytes; + return; + } events.addLast(event); + retainedBytes += bytes; available.signal(); } finally { lock.unlock(); @@ -78,12 +124,17 @@ public long droppedCount() { * @throws InterruptedException if the waiting thread is interrupted */ public Optional next() throws InterruptedException { + return nextDelivery().map(Delivery::event); + } + + /** Waits for an event and returns its loss counters under the same lock. */ + public Optional> nextDelivery() throws InterruptedException { lock.lockInterruptibly(); try { while (events.isEmpty() && !closed) { available.await(); } - return Optional.ofNullable(events.pollFirst()); + return poll(); } finally { lock.unlock(); } @@ -98,6 +149,11 @@ public Optional next() throws InterruptedException { * @throws InterruptedException if the waiting thread is interrupted */ public Optional next(Duration timeout) throws InterruptedException { + return nextDelivery(timeout).map(Delivery::event); + } + + /** Waits up to a deadline and returns the event with its atomic loss snapshot. */ + public Optional> nextDelivery(Duration timeout) throws InterruptedException { if (timeout.isNegative()) { throw new IllegalArgumentException("timeout must not be negative"); } @@ -112,7 +168,46 @@ public Optional next(Duration timeout) throws InterruptedException { while (events.isEmpty() && !closed && remaining > 0) { remaining = available.awaitNanos(remaining); } - return Optional.ofNullable(events.pollFirst()); + return poll(); + } finally { + lock.unlock(); + } + } + + private Optional> poll() { + T event = events.pollFirst(); + if (event == null) { + return Optional.empty(); + } + retainedBytes -= size.applyAsLong(event); + return Optional.of(new Delivery<>(event, dropped, droppedBytes)); + } + + /** Returns the retained payload byte count, excluding object overhead. */ + public long retainedBytes() { + lock.lock(); + try { + return retainedBytes; + } finally { + lock.unlock(); + } + } + + /** Returns the monotonic count of payload bytes discarded by overflow. */ + public long droppedBytes() { + lock.lock(); + try { + return droppedBytes; + } finally { + lock.unlock(); + } + } + + /** Returns the first terminal cause, or empty while open. */ + public Optional termination() { + lock.lock(); + try { + return Optional.ofNullable(termination); } finally { lock.unlock(); } @@ -136,13 +231,21 @@ public boolean isClosed() { /** Discards buffered events, removes this subscriber, and wakes every waiting reader. */ @Override public void close() { + end(new Termination(EndReason.CLOSED, Optional.empty())); + } + + void end(Termination reason) { lock.lock(); try { + if (reason.reason() == EndReason.CLOSED) { + events.clear(); + retainedBytes = 0; + } if (closed) { return; } closed = true; - events.clear(); + termination = reason; available.signalAll(); onClose.accept(this); } finally { diff --git a/libtmux/src/main/java/io/github/libtmux/control/PaneOutputBytes.java b/libtmux/src/main/java/io/github/libtmux/control/PaneOutputBytes.java new file mode 100644 index 00000000..141a666e --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/control/PaneOutputBytes.java @@ -0,0 +1,65 @@ +package io.github.libtmux.control; + +import io.github.libtmux.PaneId; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** Preserves one pane output chunk without interpreting its character encoding. */ +public final class PaneOutputBytes { + private final PaneId pane; + private final byte[] data; + + public PaneOutputBytes(PaneId pane, byte[] data) { + this.pane = Objects.requireNonNull(pane); + this.data = data.clone(); + } + + public PaneId pane() { + return pane; + } + + /** Returns a defensive copy of the unescaped terminal bytes. */ + public byte[] data() { + return data.clone(); + } + + public int size() { + return data.length; + } + + static PaneOutputBytes parse(byte[] line) { + int start = "%output ".length(); + int end = start; + while (end < line.length && line[end] != ' ') { + end++; + } + if (end == line.length) { + throw new IllegalArgumentException("output notification has no payload separator"); + } + PaneId pane = new PaneId(new String(line, start, end - start, StandardCharsets.US_ASCII)); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(line.length - end - 1); + for (int i = end + 1; i < line.length; i++) { + if (line[i] == '\\' + && i + 3 < line.length + && octal(line[i + 1]) + && octal(line[i + 2]) + && octal(line[i + 3])) { + bytes.write((line[i + 1] - '0') * 64 + (line[i + 2] - '0') * 8 + line[i + 3] - '0'); + i += 3; + } else { + bytes.write(line[i]); + } + } + return new PaneOutputBytes(pane, bytes.toByteArray()); + } + + private static boolean octal(byte value) { + return value >= '0' && value <= '7'; + } + + @Override + public String toString() { + return "PaneOutputBytes[" + pane + ", bytes=" + data.length + "]"; + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/control/PaneOutputDecoder.java b/libtmux/src/main/java/io/github/libtmux/control/PaneOutputDecoder.java new file mode 100644 index 00000000..869aa3ce --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/control/PaneOutputDecoder.java @@ -0,0 +1,85 @@ +package io.github.libtmux.control; + +import io.github.libtmux.PaneId; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * Decodes UTF-8 incrementally per pane on the consuming thread. + * Incomplete characters wait for the next chunk. A changed loss count resets + * every pane; bytes on opposite sides of a gap never form a character. + * Instances are not thread-safe. Call {@link #finish} at stream end to handle + * an incomplete final character under the selected malformed-byte policy. + */ +public final class PaneOutputDecoder { + private final CodingErrorAction malformed; + private final Map pending = new HashMap<>(); + private long loss; + + /** Accepts REPORT (throw) or REPLACE (U+FFFD) for malformed UTF-8. */ + public PaneOutputDecoder(CodingErrorAction malformed) { + if (!CodingErrorAction.REPORT.equals(malformed) && !CodingErrorAction.REPLACE.equals(malformed)) { + throw new IllegalArgumentException("malformed policy must be REPORT or REPLACE"); + } + this.malformed = malformed; + } + + /** Uses the loss snapshot returned atomically with an event. */ + public PaneOutput decode(EventSubscription.Delivery delivery) throws CharacterCodingException { + return decode(delivery.event(), delivery.droppedCount()); + } + + /** + * Decodes a chunk after resetting partial characters when the loss count increases. + * REPORT throws on malformed input and discards that pane's pending partial character. + */ + public PaneOutput decode(PaneOutputBytes output, long droppedCount) throws CharacterCodingException { + if (droppedCount < loss) { + throw new IllegalArgumentException("loss count decreased"); + } + if (droppedCount != loss) { + reset(); + loss = droppedCount; + } + return decode(output, false); + } + + /** Flushes an incomplete trailing character for one pane and releases its state. */ + public PaneOutput finish(PaneId pane) throws CharacterCodingException { + return decode(new PaneOutputBytes(pane, new byte[0]), true); + } + + /** Discards partial characters for all panes, for an externally reported gap. */ + public void reset() { + pending.clear(); + } + + private PaneOutput decode(PaneOutputBytes output, boolean end) throws CharacterCodingException { + byte[] prefix = pending.remove(output.pane()); + byte[] data = output.data(); + ByteBuffer input = ByteBuffer.allocate((prefix == null ? 0 : prefix.length) + data.length); + if (prefix != null) { + input.put(prefix); + } + input.put(data).flip(); + var decoder = + StandardCharsets.UTF_8.newDecoder().onMalformedInput(malformed).onUnmappableCharacter(malformed); + CharBuffer text = CharBuffer.allocate(input.remaining() + 1); + var result = decoder.decode(input, text, end); + if (result.isError()) { + result.throwException(); + } + if (input.hasRemaining()) { + byte[] remainder = new byte[input.remaining()]; + input.get(remainder); + pending.put(output.pane(), remainder); + } + text.flip(); + return new PaneOutput(output.pane(), text.toString()); + } +} diff --git a/libtmux/src/test/java/io/github/libtmux/CapturedServerTest.java b/libtmux/src/test/java/io/github/libtmux/CapturedServerTest.java new file mode 100644 index 00000000..6d43b994 --- /dev/null +++ b/libtmux/src/test/java/io/github/libtmux/CapturedServerTest.java @@ -0,0 +1,101 @@ +package io.github.libtmux; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.github.libtmux.format.RowFormat; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.TmuxTransport; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +final class CapturedServerTest { + + @Test + void everyHandleAndRelationRetainsTheSameCaptureWithoutCommands() { + AtomicInteger commands = new AtomicInteger(); + TmuxTransport transport = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + commands.incrementAndGet(); + return GroupedTmux.execute( + request, + 4242L, + argv -> new CommandResult( + 0, + switch (argv.getFirst()) { + case "display-message" -> List.of(row("4242", "3.6")); + case "list-sessions" -> + List.of(row("$0", "first", "1", "1"), row("$1", "second", "0", "1")); + case "list-windows" -> + List.of( + row("$0", "@0", "0", "shared", "1", "1", "1", "80", "24", "layout"), + row("$1", "@0", "9", "shared", "1", "1", "1", "80", "24", "layout")); + case "list-panes" -> List.of(pane("$0", "0"), pane("$1", "9")); + case "list-clients" -> List.of(row("client", "$0")); + default -> throw new AssertionError(argv); + }, + List.of())); + } + + @Override + public void close() {} + }; + try (Server server = Server.using(ServerConfig.builder().build(), transport)) { + CapturedServer captured = server.capture(); + var snapshot = captured.snapshot(); + assertEquals(2, commands.get()); + assertSame(server, captured.server()); + assertEquals(snapshot.serverPid(), captured.identity().processId()); + assertEquals(2, captured.sessions().size()); + assertEquals(2, captured.windows().size()); + assertEquals(2, captured.panes().size()); + assertEquals(1, captured.clients().size()); + captured.sessions().forEach(session -> { + assertSame(snapshot, session.snapshot()); + assertSame(snapshot, session.activeWindow().orElseThrow().snapshot()); + session.windows().forEach(window -> assertSame(snapshot, window.snapshot())); + }); + captured.windows().forEach(window -> { + assertSame(snapshot, window.snapshot()); + assertSame(snapshot, window.session().snapshot()); + assertSame(snapshot, window.activePane().orElseThrow().snapshot()); + window.panes().forEach(pane -> assertSame(snapshot, pane.snapshot())); + }); + captured.panes().forEach(pane -> { + assertSame(snapshot, pane.snapshot()); + assertSame(snapshot, pane.window().snapshot()); + }); + captured.clients().forEach(client -> { + assertSame(snapshot, client.snapshot()); + assertSame(snapshot, client.session().orElseThrow().snapshot()); + ClientAttachment attachment = client.attachment().orElseThrow(); + assertSame(snapshot, attachment.session().snapshot()); + assertSame(snapshot, attachment.activeWindow().snapshot()); + assertSame(snapshot, attachment.activePane().snapshot()); + }); + assertEquals( + List.of("$0", "$1"), + captured.sessions().stream().map(s -> s.id().value()).toList()); + assertEquals( + captured.panes().getFirst().id(), captured.panes().getLast().id()); + assertThrows( + UnsupportedOperationException.class, + () -> captured.clients().clear()); + assertEquals(2, commands.get(), "metadata, clients, and all captured relations must remain pure"); + } + } + + private static String pane(String session, String index) { + return row( + session, "@0", index, "%0", "0", "1", "sh", "80", "24", "0", "0", "title", "/", "42", "1", "1", "1", + "1"); + } + + private static String row(String... fields) { + return String.join(RowFormat.of("field").separator(), fields); + } +} diff --git a/libtmux/src/test/java/io/github/libtmux/ServerTest.java b/libtmux/src/test/java/io/github/libtmux/ServerTest.java index 1e434217..671a9c63 100644 --- a/libtmux/src/test/java/io/github/libtmux/ServerTest.java +++ b/libtmux/src/test/java/io/github/libtmux/ServerTest.java @@ -298,6 +298,42 @@ public void close() {} } } + @Test + void waitingGroupsReserveAdmissionAndPreserveTransportFailure(@TempDir Path directory) throws IOException { + AtomicInteger ordinary = new AtomicInteger(); + AtomicInteger waiting = new AtomicInteger(); + TmuxTransportException failure = + new TmuxTransportException("admission refused", DispatchOutcome.NOT_DISPATCHED, null); + TmuxTransport transport = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + ordinary.incrementAndGet(); + throw failure; + } + + @Override + public CommandResult executeWaiting(CommandRequest request) { + waiting.incrementAndGet(); + throw failure; + } + + @Override + public void close() {} + }; + try (Server server = Server.using(config(directory), transport)) { + assertSame(failure, assertThrows(TmuxTransportException.class, + () -> server.batch().add("display-message", "one").run())); + assertSame(failure, assertThrows(TmuxTransportException.class, + () -> server.chain().then("display-message", "two").run())); + assertSame(failure, assertThrows(TmuxTransportException.class, + () -> server.batchReservingCapacity().add("wait-for", "one").run())); + assertSame(failure, assertThrows(TmuxTransportException.class, + () -> server.chainReservingCapacity().then("wait-for", "two").run())); + assertEquals(2, ordinary.get()); + assertEquals(2, waiting.get()); + } + } + @Test void liveReadsPreserveMalformedOrInconsistentCaptures(@TempDir Path directory) throws IOException { String separator = RowFormat.of("field").separator(); @@ -442,6 +478,7 @@ void showingABufferDistinguishesAnAbsentDaemonFromAMissingName(@TempDir Path dir private static List liveReads(Server server) { return List.of( server::snapshot, + server::capture, server::sessions, server::windows, server::panes, 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 bdbad2b6..f4e0176e 100644 --- a/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java +++ b/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java @@ -147,6 +147,10 @@ void aTimedOutReplyMakesTheStreamUnavailableForLaterRequests(@TempDir Path direc assertEquals(DispatchOutcome.UNKNOWN, failure.outcome()); assertFalse(client.isAlive(), "a missing reply leaves command attribution uncertain"); assertTrue(events.isClosed(), "a subscriber cannot wait forever on an unusable client"); + assertEquals( + EventSubscription.EndReason.TRANSPORT_FAILURE, + events.termination().orElseThrow().reason()); + assertEquals(failure, events.termination().orElseThrow().cause().orElseThrow()); assertThrows(IllegalStateException.class, () -> client.send("list-panes")); } } @@ -175,6 +179,9 @@ void closingTheClientWakesAWaitingSubscriber(@TempDir Path directory) throws Exc client.close(); assertEquals(Optional.empty(), waiting.get(1, TimeUnit.SECONDS)); + assertEquals( + EventSubscription.EndReason.CLOSED, + output.termination().orElseThrow().reason()); } finally { waiting.cancel(true); output.close(); @@ -355,6 +362,56 @@ void aWriterFailureReclaimsDescendantsBeforeKillingTheControlProcess(@TempDir Pa } } + @Test + void terminalReasonsPreserveServerProtocolAndUnknownEnds(@TempDir Path directory) throws Exception { + var cases = java.util.Map.of( + "printf '%%exit server exited\\n'", EventSubscription.EndReason.CONTROL_EXIT, + "printf '%%output malformed\\n'", EventSubscription.EndReason.PROTOCOL_FAILURE, + ":", EventSubscription.EndReason.UNKNOWN); + int index = 0; + for (var entry : cases.entrySet()) { + Path scratch = Files.createDirectory(directory.resolve("termination-" + index++)); + ServerConfig config = fakeTmux(scratch, """ + printf '%%begin 100 1 0\n%%end 100 1 0\n' + IFS= read -r refresh + printf '%%begin 101 1 0\n%%end 101 1 0\n' + IFS= read -r trigger + """ + "printf '%%output %%1 final\\n'\n" + entry.getKey() + "\n"); + var client = ControlClient.attach(config, new SessionId("$0")); + try (var events = client.subscribeOutputBytes(2, 128)) { + assertThrows(TmuxTransportException.class, () -> client.send("trigger")); + assertEquals( + "final", + new String( + events.next(Duration.ofSeconds(1)).orElseThrow().data(), + java.nio.charset.StandardCharsets.US_ASCII)); + assertEquals(0, events.droppedCount()); + assertTrue(events.next(Duration.ZERO).isEmpty()); + assertEquals( + entry.getValue(), events.termination().orElseThrow().reason()); + if (entry.getValue() == EventSubscription.EndReason.CONTROL_EXIT) { + assertEquals( + "%exit server exited", + events.termination() + .orElseThrow() + .cause() + .orElseThrow() + .getMessage()); + } + if (entry.getValue() == EventSubscription.EndReason.PROTOCOL_FAILURE) { + assertInstanceOf( + IllegalArgumentException.class, + events.termination().orElseThrow().cause().orElseThrow()); + } + client.close(); + assertEquals( + entry.getValue(), events.termination().orElseThrow().reason()); + } finally { + client.close(); + } + } + } + private static ServerConfig fakeTmux(Path directory, String body) throws Exception { Path fakeTmux = directory.resolve("tmux"); Files.writeString(fakeTmux, "#!/bin/sh\n" + body); diff --git a/libtmux/src/test/java/io/github/libtmux/control/ControlLineReaderTest.java b/libtmux/src/test/java/io/github/libtmux/control/ControlLineReaderTest.java index 418b9bb6..e6e7a15b 100644 --- a/libtmux/src/test/java/io/github/libtmux/control/ControlLineReaderTest.java +++ b/libtmux/src/test/java/io/github/libtmux/control/ControlLineReaderTest.java @@ -1,5 +1,6 @@ package io.github.libtmux.control; +import static java.util.Objects.requireNonNull; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -22,8 +23,10 @@ void aLineCannotGrowPastItsByteLimit() throws Exception { void lineEndingsAreRemovedButCounted() throws Exception { byte[] input = "one\r\ntwo".getBytes(StandardCharsets.UTF_8); try (var lines = new ControlLineReader(new ByteArrayInputStream(input), 8)) { - assertEquals(new ControlLineReader.Line("one", 4), lines.readLine()); - assertEquals(new ControlLineReader.Line("two", 3), lines.readLine()); + var one = requireNonNull(lines.readLine()); + assertEquals("one", one.text()); + assertEquals(4, one.encodedBytes()); + assertEquals("two", requireNonNull(lines.readLine()).text()); assertEquals(null, lines.readLine()); } } @@ -33,7 +36,7 @@ void malformedUtf8RemainsRecoverableAsItsOriginalByte() throws Exception { byte[] input = {'a', (byte) 0xff, '\n'}; try (var lines = new ControlLineReader(new ByteArrayInputStream(input), 8)) { - assertEquals(new ControlLineReader.Line("a\\xff", 2), lines.readLine()); + assertEquals("a\\xff", requireNonNull(lines.readLine()).text()); } } } diff --git a/libtmux/src/test/java/io/github/libtmux/control/PaneOutputBytesTest.java b/libtmux/src/test/java/io/github/libtmux/control/PaneOutputBytesTest.java new file mode 100644 index 00000000..6be7b376 --- /dev/null +++ b/libtmux/src/test/java/io/github/libtmux/control/PaneOutputBytesTest.java @@ -0,0 +1,187 @@ +package io.github.libtmux.control; + +import static java.util.Objects.requireNonNull; +import static org.junit.jupiter.api.Assertions.*; + +import io.github.libtmux.PaneId; +import java.io.ByteArrayInputStream; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +final class PaneOutputBytesTest { + private static final PaneId FIRST = new PaneId("%1"); + private static final PaneId SECOND = new PaneId("%2"); + + @Test + void rawLinesPreserveSplitAndInvalidBytesBeforeTextDecoding() throws Exception { + byte[] raw = { + '%', + 'o', + 'u', + 't', + 'p', + 'u', + 't', + ' ', + '%', + '1', + ' ', + (byte) 0xc3, + '\n', + '%', + 'o', + 'u', + 't', + 'p', + 'u', + 't', + ' ', + '%', + '1', + ' ', + (byte) 0xa9, + (byte) 0xff, + '\n' + }; + var decoder = new PaneOutputDecoder(CodingErrorAction.REPLACE); + try (var reader = new ControlLineReader(new ByteArrayInputStream(raw), 100)) { + var first = PaneOutputBytes.parse(requireNonNull(reader.readLine()).bytes()); + var second = PaneOutputBytes.parse(requireNonNull(reader.readLine()).bytes()); + assertArrayEquals(new byte[] {(byte) 0xc3}, first.data()); + assertArrayEquals(new byte[] {(byte) 0xa9, (byte) 0xff}, second.data()); + assertEquals("", decoder.decode(first, 0).data()); + assertEquals("é�", decoder.decode(second, 0).data()); + } + } + + @Test + void everyUtf8SplitBoundaryDecodesWithoutReplacement() throws Exception { + for (String expected : java.util.List.of("é", "€", "😀")) { + byte[] encoded = expected.getBytes(StandardCharsets.UTF_8); + for (int split = 1; split < encoded.length; split++) { + var decoder = new PaneOutputDecoder(CodingErrorAction.REPORT); + assertEquals( + "", + decoder.decode(new PaneOutputBytes(FIRST, java.util.Arrays.copyOf(encoded, split)), 0) + .data()); + assertEquals( + expected, + decoder.decode( + new PaneOutputBytes( + FIRST, java.util.Arrays.copyOfRange(encoded, split, encoded.length)), + 0) + .data()); + } + } + } + + @Test + void escapesAreBytesAndPayloadCopiesCannotMutateAnEvent() { + var output = PaneOutputBytes.parse("%output %1 \\303\\251\\377\\134".getBytes(StandardCharsets.US_ASCII)); + assertArrayEquals(new byte[] {(byte) 0xc3, (byte) 0xa9, (byte) 0xff, '\\'}, output.data()); + byte[] source = {(byte) 0xff}; + var immutable = new PaneOutputBytes(FIRST, source); + source[0] = 0; + immutable.data()[0] = 0; + assertEquals((byte) 0xff, immutable.data()[0]); + } + + @Test + void panesKeepIndependentPartialCharactersAndGapsResetThem() throws Exception { + var decoder = new PaneOutputDecoder(CodingErrorAction.REPLACE); + assertEquals("", decoder.decode(bytes(FIRST, 0xc3), 0).data()); + assertEquals("", decoder.decode(bytes(SECOND, 0xe2, 0x82), 0).data()); + assertEquals("é", decoder.decode(bytes(FIRST, 0xa9), 0).data()); + assertEquals("€", decoder.decode(bytes(SECOND, 0xac), 0).data()); + decoder.decode(bytes(FIRST, 0xc3), 0); + decoder.decode(bytes(SECOND, 0xc3), 0); + assertEquals("�", decoder.decode(bytes(FIRST, 0xa9), 1).data()); + assertEquals("�", decoder.decode(bytes(SECOND, 0xa9), 1).data()); + assertThrows(IllegalArgumentException.class, () -> decoder.decode(bytes(FIRST, 65), 0)); + } + + @Test + void malformedPolicyAlsoAppliesAtEndOfStream() throws Exception { + var strict = new PaneOutputDecoder(CodingErrorAction.REPORT); + assertThrows(CharacterCodingException.class, () -> strict.decode(bytes(FIRST, 0xff), 0)); + strict.decode(bytes(FIRST, 0xe2), 0); + assertThrows(CharacterCodingException.class, () -> strict.finish(FIRST)); + var replacing = new PaneOutputDecoder(CodingErrorAction.REPLACE); + replacing.decode(bytes(FIRST, 0xe2), 0); + assertEquals("�", replacing.finish(FIRST).data()); + assertEquals("", replacing.finish(FIRST).data()); + } + + @Test + void eventAndByteBoundsReportAtomicMonotonicLossAndFirstTermination() throws Exception { + var events = new EventSubscription(2, 3, PaneOutputBytes::size, ignored -> {}); + try { + events.offer(bytes(FIRST, 1, 2)); + events.offer(bytes(FIRST, 3, 4)); + assertEquals(2, events.retainedBytes()); + var next = events.nextDelivery(Duration.ZERO).orElseThrow(); + assertEquals(1, next.droppedCount()); + assertEquals(2, next.droppedBytes()); + assertEquals(0, events.retainedBytes()); + events.offer(bytes(FIRST, 1)); + events.offer(bytes(FIRST, 2)); + events.offer(bytes(FIRST, 3)); + assertEquals(2, events.droppedCount()); + events.offer(bytes(FIRST, 1, 2, 3, 4)); + assertEquals(5, events.droppedCount()); + assertEquals(9, events.droppedBytes()); + assertEquals(0, events.retainedBytes()); + assertTrue(events.nextDelivery(Duration.ZERO).isEmpty()); + var failure = new IllegalStateException("original"); + events.end(new EventSubscription.Termination( + EventSubscription.EndReason.PROTOCOL_FAILURE, Optional.of(failure))); + events.close(); + assertEquals( + EventSubscription.EndReason.PROTOCOL_FAILURE, + events.termination().orElseThrow().reason()); + assertSame(failure, events.termination().orElseThrow().cause().orElseThrow()); + } finally { + events.close(); + } + } + + @Test + void remoteTerminationPreservesFinalOutputAndIntentionalCloseDiscardsIt() throws Exception { + try (var events = new EventSubscription(2, 16, PaneOutputBytes::size, ignored -> {})) { + events.offer(bytes(FIRST, 65)); + events.end(new EventSubscription.Termination(EventSubscription.EndReason.CONTROL_EXIT, Optional.empty())); + assertTrue(events.isClosed()); + assertEquals(1, events.retainedBytes()); + var delivery = events.nextDelivery(Duration.ZERO).orElseThrow(); + assertArrayEquals(new byte[] {65}, delivery.event().data()); + assertEquals(0, delivery.droppedCount()); + assertEquals(0, delivery.droppedBytes()); + assertTrue(events.nextDelivery(Duration.ZERO).isEmpty()); + } + var events = new EventSubscription(2, 16, PaneOutputBytes::size, ignored -> {}); + try { + events.offer(bytes(FIRST, 65)); + events.end(new EventSubscription.Termination(EventSubscription.EndReason.CONTROL_EXIT, Optional.empty())); + events.close(); + assertEquals(0, events.retainedBytes()); + assertTrue(events.nextDelivery(Duration.ZERO).isEmpty()); + assertEquals( + EventSubscription.EndReason.CONTROL_EXIT, + events.termination().orElseThrow().reason()); + } finally { + events.close(); + } + } + + private static PaneOutputBytes bytes(PaneId pane, int... values) { + byte[] bytes = new byte[values.length]; + for (int i = 0; i < values.length; i++) { + bytes[i] = (byte) values[i]; + } + return new PaneOutputBytes(pane, bytes); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 74d8a922..fcb2b5d8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,6 +18,8 @@ include("libtmux") include("libtmux-bom") include("libtmux-jackson") include("libtmux-kotlin") +include("libtmux-clojure") +include("libtmux-clojure-core-async") include("libtmux-junit5") include("libtmux-workspace") include("libtmux-mcp") From 8e147cee09f4f42107383b10aebba274432346c1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 14:35:55 -0500 Subject: [PATCH 02/11] Clojure(style[tests]): Format transport failure test why: Match the repository Java formatter. what: - Wrap group failure assertions in the standard layout. --- .../java/io/github/libtmux/ServerTest.java | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/libtmux/src/test/java/io/github/libtmux/ServerTest.java b/libtmux/src/test/java/io/github/libtmux/ServerTest.java index 671a9c63..523399ae 100644 --- a/libtmux/src/test/java/io/github/libtmux/ServerTest.java +++ b/libtmux/src/test/java/io/github/libtmux/ServerTest.java @@ -321,14 +321,30 @@ public CommandResult executeWaiting(CommandRequest request) { public void close() {} }; try (Server server = Server.using(config(directory), transport)) { - assertSame(failure, assertThrows(TmuxTransportException.class, - () -> server.batch().add("display-message", "one").run())); - assertSame(failure, assertThrows(TmuxTransportException.class, - () -> server.chain().then("display-message", "two").run())); - assertSame(failure, assertThrows(TmuxTransportException.class, - () -> server.batchReservingCapacity().add("wait-for", "one").run())); - assertSame(failure, assertThrows(TmuxTransportException.class, - () -> server.chainReservingCapacity().then("wait-for", "two").run())); + assertSame( + failure, + assertThrows( + TmuxTransportException.class, + () -> server.batch().add("display-message", "one").run())); + assertSame( + failure, + assertThrows( + TmuxTransportException.class, + () -> server.chain().then("display-message", "two").run())); + assertSame( + failure, + assertThrows( + TmuxTransportException.class, + () -> server.batchReservingCapacity() + .add("wait-for", "one") + .run())); + assertSame( + failure, + assertThrows( + TmuxTransportException.class, + () -> server.chainReservingCapacity() + .then("wait-for", "two") + .run())); assertEquals(2, ordinary.get()); assertEquals(2, waiting.get()); } From 508f19cc9a49a782961f2dd54c9e6042cd2b4a8e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 14:41:00 -0500 Subject: [PATCH 03/11] Clojure(fix[fixtures]): Await worker termination why: Task completion can publish before the virtual worker stops. what: - Join the owned fixture worker before asserting its cleanup. --- .../src/test/clojure/libtmux/internal/fixture_test.clj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj b/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj index cc9fca25..e99964e3 100644 --- a/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj +++ b/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj @@ -71,6 +71,10 @@ (async/close-runtime! runtime)))] (when (map? owned) (is (false? (.isAlive ^ProcessHandle (:process owned)))) + ;; Task completion is published before its virtual worker returns to + ;; the executor. Wait for that worker's terminal transition before + ;; asserting fixture ownership cleanup. + (.join ^Thread (:worker owned) 900) (is (false? (.isAlive ^Thread (:worker owned)))) (is (not (exists? (:socket owned)))) (is (not (exists? (:directory owned)))))))) From d6c65074e9052e8c7b4a37573002918d1f8198fd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 15:24:16 -0500 Subject: [PATCH 04/11] Clojure(fix[fixtures]): Match socket aliases why: macOS resolves fixture socket paths through /private/tmp while tmux preserves the original /tmp spelling in its command arguments. what: - Match sockets by their resolved paths during ownership checks. - Cover ancestor-link aliases and control-process ownership. --- .../libtmux/control_integration_test.clj | 2 +- .../test/clojure/libtmux/internal/fixture.clj | 14 +++++++--- .../clojure/libtmux/internal/fixture_test.clj | 27 +++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/integration-tests/src/test/clojure/libtmux/control_integration_test.clj b/integration-tests/src/test/clojure/libtmux/control_integration_test.clj index dd8deb69..11f3bbdb 100644 --- a/integration-tests/src/test/clojure/libtmux/control_integration_test.clj +++ b/integration-tests/src/test/clojure/libtmux/control_integration_test.clj @@ -184,7 +184,7 @@ (is (.isAlive process)) (is (= (.pid (ProcessHandle/current)) (.pid ^ProcessHandle parent))) (is (some #{"-C"} argv)) - (is (some #{(str socket)} argv)) + (is (some #(fixture/same-socket-path? socket %) argv)) (reset! owned {:process process :started started :argv argv :exit (.onExit process)}) (when exceptional? (throw expected)))) diff --git a/integration-tests/src/test/clojure/libtmux/internal/fixture.clj b/integration-tests/src/test/clojure/libtmux/internal/fixture.clj index 9710af0e..f246ab8a 100644 --- a/integration-tests/src/test/clojure/libtmux/internal/fixture.clj +++ b/integration-tests/src/test/clojure/libtmux/internal/fixture.clj @@ -2,6 +2,7 @@ (:import [io.github.libtmux Server ServerConfig ServerConfig$Builder ServerEndpoint] [io.github.libtmux.junit5 NamedServerFixture] [io.github.libtmux.transport CommandResult] + [java.io IOException] [java.lang AutoCloseable ProcessHandle ProcessHandle$Info] [java.nio.file Files Path LinkOption] [java.nio.file.attribute BasicFileAttributes] @@ -51,19 +52,26 @@ (defn- basename [^String path] (when path (str (.getFileName (Path/of path (make-array String 0)))))) +(defn same-socket-path? [^Path expected ^String observed] + (try + (let [^"[Ljava.nio.file.LinkOption;" options (make-array LinkOption 0) + ^Path actual (Path/of observed (make-array String 0))] + (= (.toRealPath expected options) (.toRealPath actual options))) + (catch IOException _ false) + (catch java.nio.file.InvalidPathException _ false))) + (defn- tmux-for-socket? [identity binary ^Path socket] (let [command (:command identity) arguments (:arguments identity) executable (basename command) - expected (basename binary) - socket (str (.toAbsolutePath socket))] + expected (basename binary)] (and (= expected executable) (some true? (map-indexed (fn [index argument] (and (= "-S" argument) (< (inc index) (count arguments)) - (= socket (nth arguments (inc index))))) + (same-socket-path? socket (nth arguments (inc index))))) arguments))))) (defn- socket-key [^Path socket] diff --git a/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj b/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj index e99964e3..75f28182 100644 --- a/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj +++ b/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj @@ -14,6 +14,10 @@ (let [capture (ns-resolve 'libtmux.internal.fixture 'capture-process)] (capture binary socket))) +(defn- tmux-for-socket? [identity binary socket] + (let [matches? (ns-resolve 'libtmux.internal.fixture 'tmux-for-socket?)] + (matches? identity binary socket))) + (defn- await-exit! [^ProcessHandle process] (.get (.onExit process) 900 TimeUnit/MILLISECONDS)) @@ -79,6 +83,29 @@ (is (not (exists? (:socket owned)))) (is (not (exists? (:directory owned)))))))) +(deftest fixture-recognizes-a-socket-through-an-ancestor-link + (let [attributes (make-array java.nio.file.attribute.FileAttribute 0) + root (Files/createTempDirectory (Path/of "/tmp/libtmux-java-test" + (make-array String 0)) + "path-alias-" attributes) + real (.resolve root "real") + socket (.resolve real "s") + alias (.resolve root "alias") + linked-socket (.resolve alias "s")] + (try + (Files/createDirectory real attributes) + (Files/createFile socket attributes) + (Files/createSymbolicLink alias real attributes) + (is (true? + (tmux-for-socket? {:command "/usr/bin/tmux" + :arguments ["-S" (str linked-socket)]} + "/usr/bin/tmux" socket))) + (finally + (Files/deleteIfExists alias) + (Files/deleteIfExists socket) + (Files/deleteIfExists real) + (Files/deleteIfExists root))))) + (deftest owned-server-is-removed-after-success (let [seen (atom nil)] (fixture/with-owned-server From 87378cbab6d1c877db7efffa9cd690e5119a759c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 15:24:23 -0500 Subject: [PATCH 05/11] Clojure(docs[lifecycle]): Explain lazy scopes why: Captured values can outlive a client, while capture I/O must finish within the server's resource scope. what: - Add an executable lazy traversal and eager capture example. - Check the capture count in the documentation test. --- docs/guide/clojure.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/guide/clojure.md b/docs/guide/clojure.md index 1f1a5f82..d144a597 100644 --- a/docs/guide/clojure.md +++ b/docs/guide/clojure.md @@ -23,6 +23,35 @@ capture. Failed acquisition throws an `ExceptionInfo`; it is never represented as an empty collection. `data/data` returns a detached EDN projection without the Java refs required for effects or Java filter evaluation. +## Resource scopes and laziness + +Acquisition returns eager captured values. A lazy `filter` or `map` over those +values reads only Clojure data, so it can escape the `with-open` scope. An I/O +operation such as `capture!` is different: realize it inside the scope that +owns its server. Do not return `(map #(tm/capture! (:tmux/ref %)) panes)` from +`with-open`; use `mapv`, `into`, or another eager reduction before closing. + + +```clojure +(require '[libtmux.core :as tm] + '[libtmux.data :as data]) + +(let [socket-path (java.nio.file.Path/of + (System/getProperty "libtmux.docs.socket") + (make-array String 0)) + {:keys [lazy-names captures]} + (with-open [server (tm/open! {:socket-path socket-path})] + (let [session (tm/new-session! server {:name "docs-lazy"}) + pane (first (data/panes (first (data/windows session))))] + {:lazy-names (->> (tm/sessions! server) + (filter #(= "docs-lazy" (:session/name %))) + (map :session/name)) + :captures (mapv #(tm/capture! (:tmux/ref %) {:from :history}) + [pane])}))] + [(vec lazy-names) (count captures)]) +;; => [["docs-lazy"] 1] +``` + ```clojure (require '[libtmux.core :as tm] From 1b11e2b50ae8c25f7a872622bfeca8ae83c6a37e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 15:50:06 -0500 Subject: [PATCH 06/11] Test(fix[if-shell]): Observe guarded option why: Window auto-renaming races shell startup on macOS, making the false-condition test report an unrelated title change as an effect. what: - Assert a private option that only the guarded command can change. - Remove the fixed delay and window-title dependency. --- .../libtmux/it/ServerControlIntegrationTest.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 1608a7c1..9beb66d4 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 @@ -60,14 +60,13 @@ void aFalseConditionRunsTheOtherOne(Server server) throws Exception { } @Test - void aFalseConditionWithNoOtherCommandDoesNothing(Server server) throws Exception { - Session session = server.sessions().get(0); - String before = session.windows().get(0).name(); + void aFalseConditionWithNoOtherCommandDoesNothing(Server server) { + String option = "@if-shell-guard"; + server.globalOptions().set(option, "before"); - server.ifShell("false", "rename-window should-not-run"); - Thread.sleep(400); + server.ifShell("false", "set-option -g " + option + " should-not-run"); - assertEquals(before, session.refresh().windows().get(0).name(), "something ran that should not have"); + assertEquals("before", server.globalOptions().get(option).orElseThrow(), "something ran that should not have"); } @Test From bf4264ff15573398adedf7d3e79ef307a8874c60 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 16:13:29 -0500 Subject: [PATCH 07/11] Clojure(test[async]): Await cancelled completion why: Cancellation publishes the terminal outcome asynchronously. what: - Observe the cancelled task before resubmission - Remove the JDK 25 admission timing race --- libtmux-clojure/src/test/clojure/libtmux/async_test.clj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libtmux-clojure/src/test/clojure/libtmux/async_test.clj b/libtmux-clojure/src/test/clojure/libtmux/async_test.clj index a9d3451e..8df2ca1d 100644 --- a/libtmux-clojure/src/test/clojure/libtmux/async_test.clj +++ b/libtmux-clojure/src/test/clojure/libtmux/async_test.clj @@ -141,6 +141,10 @@ waiting (async/submit! runtime {:kind :wait} #(.await release)) queued (async/submit! runtime identity)] (is (true? (async/cancel! queued))) + (let [outcome (try (deref queued 500 ::timeout) + (catch clojure.lang.ExceptionInfo error error))] + (is (instance? clojure.lang.ExceptionInfo outcome)) + (is (= :cancelled (:tmux/error (ex-data outcome))))) (is (instance? libtmux.async.Task (async/submit! runtime identity))) (.countDown release) (deref running 500 ::timeout) From ad567ef956786f41d44bb7afd7d7abea14fa322c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 16:49:10 -0500 Subject: [PATCH 08/11] Clojure(fix[fixture]): Identify the tmux daemon why: Fixture scanning conflated a tmux client with its daemon. what: - Read the authenticated daemon PID from tmux - Cover concurrent client ownership --- .../test/clojure/libtmux/internal/fixture.clj | 32 ++++++++------- .../clojure/libtmux/internal/fixture_test.clj | 39 ++++++++++++++----- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/integration-tests/src/test/clojure/libtmux/internal/fixture.clj b/integration-tests/src/test/clojure/libtmux/internal/fixture.clj index f246ab8a..809aead5 100644 --- a/integration-tests/src/test/clojure/libtmux/internal/fixture.clj +++ b/integration-tests/src/test/clojure/libtmux/internal/fixture.clj @@ -11,7 +11,7 @@ [java.util.concurrent TimeUnit])) (def ^:private ^Path socket-root (Path/of "/tmp/libtmux-java-test" (make-array String 0))) -(def ^:dynamic *after-start* (fn [_])) +(def ^:dynamic *after-start* (fn [_ _])) (defn- open-server [^Path socket ^Path config] (let [^ServerConfig$Builder builder (ServerConfig/builder)] @@ -79,16 +79,20 @@ ^BasicFileAttributes attributes (Files/readAttributes socket BasicFileAttributes options)] (.fileKey attributes))) -(defn- capture-process [binary socket] - (let [matches (->> (iterator-seq (.iterator (ProcessHandle/allProcesses))) - (map process-identity) - (filter #(tmux-for-socket? % binary socket)) - vec)] - (when (> (count matches) 1) - (throw (ex-info "multiple tmux processes claim the fixture socket" - {:socket socket :pids (mapv :pid matches)}))) - (when-let [owned (first matches)] - (assoc owned :socket-key (socket-key socket))))) +(defn- capture-process [^Server server ^Path socket] + (let [^CommandResult result (command! server ["display-message" "-p" + "#{pid}\t#{socket_path}"]) + rows (.stdout result)] + (when-not (.succeeded result) + (throw (ex-info "could not read fixture tmux identity" {:stderr (.stderr result)}))) + (when-not (= 1 (count rows)) + (throw (ex-info "tmux reported ambiguous fixture identities" {:rows rows}))) + (let [[pid reported-socket] (.split ^String (first rows) "\\t" -1) + process (.orElseThrow (ProcessHandle/of (Long/parseLong pid)))] + (when-not (same-socket-path? socket reported-socket) + (throw (ex-info "tmux reported another fixture socket" + {:socket socket :reported-socket reported-socket}))) + (assoc (process-identity process) :socket-key (socket-key socket))))) (defn- same-process? [owned] (let [^ProcessHandle process (:process owned) @@ -145,8 +149,8 @@ (str "clj-" (UUID/randomUUID))])] (when-not (.succeeded created) (throw (ex-info "could not start fixture tmux" {:stderr (.stderr created)})))) - (reset! captured (capture-process binary socket)) - (*after-start* socket) + (reset! captured (capture-process server socket)) + (*after-start* server socket) (let [^NamedServerFixture owner (NamedServerFixture/own server socket socket-root)] (try (let [expected (System/getProperty "libtmux.tmux.expected") @@ -173,7 +177,7 @@ (reset! primary failure) (when-not @captured (try - (reset! captured (capture-process binary socket)) + (reset! captured (capture-process server socket)) (catch Throwable cleanup (.addSuppressed failure cleanup)))) (when (and (nil? @captured) diff --git a/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj b/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj index 75f28182..0cdfdd0f 100644 --- a/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj +++ b/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj @@ -10,9 +10,9 @@ (Files/exists socket (make-array java.nio.file.LinkOption 0))) -(defn- capture-fixture-process [binary socket] +(defn- capture-fixture-process [server socket] (let [capture (ns-resolve 'libtmux.internal.fixture 'capture-process)] - (capture binary socket))) + (capture server socket))) (defn- tmux-for-socket? [identity binary socket] (let [matches? (ns-resolve 'libtmux.internal.fixture 'tmux-for-socket?)] @@ -21,9 +21,9 @@ (defn- await-exit! [^ProcessHandle process] (.get (.onExit process) 900 TimeUnit/MILLISECONDS)) -(defn- replace-fixture-daemon! [^Path socket] +(defn- replace-fixture-daemon! [server ^Path socket] (let [binary (System/getProperty "libtmux.tmux" "tmux") - original (capture-fixture-process binary socket) + original (capture-fixture-process server socket) directory (.getParent socket) config (.resolve directory "foreign.conf")] (when-not original @@ -36,7 +36,7 @@ "new-session" "-d" "-s" "foreign-fixture"] ^Process process (.start (ProcessBuilder. arguments)) completed? (.waitFor process 900 TimeUnit/MILLISECONDS) - replacement (capture-fixture-process binary socket)] + replacement (capture-fixture-process server socket)] (when-not (and completed? (zero? (.exitValue process)) replacement) (throw (ex-info "could not start a replacement tmux process" {:socket socket}))) (assoc replacement :socket socket :config config)))) @@ -47,8 +47,8 @@ ^libtmux.async.AsyncRuntime runtime (async/runtime! {:close-timeout-ms 900}) task (async/submit! runtime options #(fixture/with-owned-server - (fn [{:keys [socket binary]}] - (let [owner (capture-fixture-process binary socket)] + (fn [{:keys [server socket]}] + (let [owner (capture-fixture-process server socket)] (deliver started {:socket socket :directory (.getParent ^Path socket) :process (:process owner) @@ -124,11 +124,30 @@ (throw (Exception. "fixture failure")))))) (is (not (exists? @seen))))) +(deftest fixture-captures-the-daemon-with-a-live-client + (fixture/with-owned-server + (fn [{:keys [server socket binary]}] + (let [channel (str "fixture-process-" (java.util.UUID/randomUUID)) + ^java.util.List client-arguments [binary "-S" (str socket) + "wait-for" channel] + ^Process client (.start (ProcessBuilder. client-arguments))] + (try + (is (.isAlive client)) + (let [owned (capture-fixture-process server socket)] + (is (instance? ProcessHandle (:process owned))) + (is (not= (.pid client) (:pid owned)))) + (finally + (let [^java.util.List signal-arguments [binary "-S" (str socket) + "wait-for" "-S" channel] + ^Process signal (.start (ProcessBuilder. signal-arguments))] + (.waitFor signal 900 TimeUnit/MILLISECONDS)) + (.waitFor client 900 TimeUnit/MILLISECONDS))))))) + (deftest server-is-removed-when-setup-fails-after-start (let [seen (atom nil)] (is (thrown? Exception (binding [fixture/*after-start* - (fn [socket] + (fn [_ socket] (reset! seen socket) (throw (Exception. "setup failure")))] (fixture/with-owned-server (fn [_]))))) @@ -165,8 +184,8 @@ (let [replacement (atom nil) failure (try (binding [fixture/*after-start* - (fn [socket] - (reset! replacement (replace-fixture-daemon! socket)) + (fn [server socket] + (reset! replacement (replace-fixture-daemon! server socket)) (throw (ex-info "fixture process was replaced" {:socket socket})))] (fixture/with-owned-server (fn [_]))) nil From 54d4e62190298bf72ef91c8519c8a8becf3d4dac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 18:08:18 -0500 Subject: [PATCH 09/11] Clojure(test[clients]): Cover switch and detach why: Client operations need live evidence that preserves the captured target. what: - Attach an owned control client in the Clojure integration fixture - Assert switch and detach through refreshed client observations --- .../clojure/libtmux/core_integration_test.clj | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/integration-tests/src/test/clojure/libtmux/core_integration_test.clj b/integration-tests/src/test/clojure/libtmux/core_integration_test.clj index 0f137655..23cab8d1 100644 --- a/integration-tests/src/test/clojure/libtmux/core_integration_test.clj +++ b/integration-tests/src/test/clojure/libtmux/core_integration_test.clj @@ -1,8 +1,10 @@ (ns libtmux.core-integration-test (:require [clojure.test :refer [deftest is]] + [libtmux.control :as control] [libtmux.core :as tmux] [libtmux.data :as data] - [libtmux.internal.fixture :as fixture])) + [libtmux.internal.fixture :as fixture]) + (:import [java.lang AutoCloseable])) (deftest typed-effects-preserve-captured-data-and-explicit-reference (fixture/with-owned-server @@ -72,6 +74,22 @@ (is (nil? (tmux/refresh! (:tmux/ref pane)))) (is (some? (tmux/refresh! (:tmux/ref window))))))))) +(deftest typed-client-operations-switch-and-detach-the-captured-client + (fixture/with-owned-server + (fn [{:keys [server]}] + (let [source (tmux/new-session! server {:name "client-source"}) + target (tmux/new-session! server {:name "client-target"})] + (with-open [^AutoCloseable connection (control/attach! (:tmux/ref source))] + (let [client (data/one-or-none (tmux/clients! server))] + (is (some? client)) + (is (nil? (tmux/switch-client! (:tmux/ref client) (:tmux/ref target)))) + (is (= "client-target" + (get-in (tmux/refresh! (:tmux/ref client)) + [:client/session :session/name]))) + (is (nil? (tmux/detach-client! (:tmux/ref client)))) + (is (nil? (tmux/refresh! (:tmux/ref client)))) + (is (seq (tmux/sessions! server))))))))) + (deftest closing-resources-does-not-kill-borrowed-server (fixture/with-owned-server (fn [{:keys [server socket binary]}] From 5e68369611966dd28150fc54dae7750ecd663fc5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 18:08:26 -0500 Subject: [PATCH 10/11] Clojure(fix[async]): Await callback cancellation why: Closing a runtime could test completion-executor quiescence before its queued callback cancellations ran. what: - Snapshot callback stages and await their scheduled cancellation - Keep the wait within the runtime's existing cleanup deadline - Record the caller-visible close behavior in the changelog --- CHANGELOG.md | 6 ++++++ .../src/main/clojure/libtmux/async.clj | 16 ++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1acb90c8..f5bd2a66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,12 @@ production. now bound payload bytes as well as event count; see [migration guidance](MIGRATION.md#control-subscriptions-bound-bytes-and-report-termination). +### Fixed + +- **`close-runtime!` waits for scheduled callback cancellation.** + Closing a runtime with finite pending callbacks no longer reports cleanup as + pending before its completion executor can terminate. + ## 0.0.1-alpha.12 — 2026-09-19 ### Added diff --git a/libtmux-clojure/src/main/clojure/libtmux/async.clj b/libtmux-clojure/src/main/clojure/libtmux/async.clj index fe97af00..bc156dca 100644 --- a/libtmux-clojure/src/main/clojure/libtmux/async.clj +++ b/libtmux-clojure/src/main/clojure/libtmux/async.clj @@ -301,11 +301,19 @@ ;; into the registry. (doseq [task @(.-tasks runtime)] (cancel! task)) (when first-close? - (locking (.-lifecycle-lock runtime) - (doseq [^CompletableFuture stage @(.-callback-stages runtime)] + (let [stages (locking (.-lifecycle-lock runtime) + @(.-callback-stages runtime)) + cancelled (CountDownLatch. (count stages))] + (doseq [^CompletableFuture stage stages] (.execute completion - ^Runnable #(.completeExceptionally stage (failure :cancelled :unknown))))) - (doseq [^ExecutorService executor workers] (.shutdownNow executor))) + ^Runnable #(try + (.completeExceptionally stage (failure :cancelled :unknown)) + (finally (.countDown cancelled))))) + (doseq [^ExecutorService executor workers] (.shutdownNow executor)) + (.await cancelled + (max 0 (.toMillis TimeUnit/NANOSECONDS + (- deadline (System/nanoTime)))) + TimeUnit/MILLISECONDS))) (doseq [^ExecutorService executor workers] (.awaitTermination executor (max 0 (.toMillis TimeUnit/NANOSECONDS From b86d43eeeee0ba256bd55943189cdff2e9e8f815 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 18:28:47 -0500 Subject: [PATCH 11/11] Clojure(ci[matrix]): Cover runtime grid why: A labeled matrix cannot establish Clojure compatibility unless the selected runtime and tmux binary are verified by the process under test. what: - Add four Clojure/JDK check and package cells - Add forty Linux and eight macOS Clojure real-tmux cells - Assert the Clojure runtime selected for each integration run --- .github/workflows/clojure-compatibility.yml | 237 ++++++++++++++++++ integration-tests/build.gradle.kts | 1 + .../clojure/libtmux/internal/fixture_test.clj | 6 + 3 files changed, 244 insertions(+) create mode 100644 .github/workflows/clojure-compatibility.yml diff --git a/.github/workflows/clojure-compatibility.yml b/.github/workflows/clojure-compatibility.yml new file mode 100644 index 00000000..c3b6d6d9 --- /dev/null +++ b/.github/workflows/clojure-compatibility.yml @@ -0,0 +1,237 @@ +name: Clojure compatibility + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + check: + name: Clojure checks (JDK ${{ matrix.java }}, Clojure ${{ matrix.clojure }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + java: ['21', '25'] + clojure: ['1.12.4', '1.12.6'] + steps: + - uses: actions/checkout@v7 + + - name: Install tmux + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y tmux + tmux -V + + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: ${{ matrix.java }} + + - uses: gradle/actions/setup-gradle@v6 + with: + validate-wrappers: true + + - name: Run Clojure checks and publish local artifacts + run: >- + ./gradlew check clojureDependencyMetadata platformCoversEveryPublishedModule + publishToMavenLocal --rerun-tasks --stacktrace + -PlibtmuxJavaVersion=${{ matrix.java }} + -PlibtmuxClojureVersion=${{ matrix.clojure }} + + - name: No tmux server outlived the suite + if: always() + run: | + left="$(pgrep -c tmux || true)" + if [ "${left:-0}" -ne 0 ]; then + echo "::error::$left tmux server(s) survived the suite" + for p in $(pgrep tmux); do tr '\0' ' ' < "/proc/$p/cmdline"; echo; done + exit 1 + fi + echo "no tmux server survived the suite" + + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: reports-clojure-jdk${{ matrix.java }}-${{ matrix.clojure }} + path: '**/build/reports/**' + retention-days: 7 + + tmux: + name: Clojure tmux ${{ matrix.tmux }} (JDK ${{ matrix.java }}, Clojure ${{ matrix.clojure }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + java: ['21', '25'] + clojure: ['1.12.4', '1.12.6'] + tmux: ['3.2a', '3.3', '3.3a', '3.4', '3.5', '3.6', '3.7', '3.7a', '3.7b', '3.7c'] + steps: + - uses: actions/checkout@v7 + + - name: Build tmux dependencies + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y \ + build-essential libevent-dev libncurses-dev bison pkg-config + + - name: Restore tmux ${{ matrix.tmux }} + id: tmux-cache + uses: actions/cache@v6 + with: + path: ~/tmux-builds/${{ matrix.tmux }} + key: tmux-${{ matrix.tmux }}-${{ runner.os }}-${{ runner.arch }} + + - name: Build tmux ${{ matrix.tmux }} + if: steps.tmux-cache.outputs.cache-hit != 'true' + env: + LANE: ${{ matrix.tmux }} + run: | + set -eux + work="$(mktemp -d)" + curl -fsSL -o "$work/tmux.tar.gz" \ + "https://github.com/tmux/tmux/releases/download/$LANE/tmux-$LANE.tar.gz" + tar -xzf "$work/tmux.tar.gz" -C "$work" + cd "$work/tmux-$LANE" + ./configure --prefix="$HOME/tmux-builds/$LANE" + make -j"$(nproc)" + make install + + - name: The lane runs the tmux it is named after + env: + LANE: ${{ matrix.tmux }} + run: | + "$HOME/tmux-builds/$LANE/bin/tmux" -V + + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: ${{ matrix.java }} + + - uses: gradle/actions/setup-gradle@v6 + with: + validate-wrappers: true + + - name: Run the native Clojure real-tmux suite + env: + LANE: ${{ matrix.tmux }} + run: | + ./gradlew :integration-tests:clojureIntegrationTest --rerun-tasks --stacktrace \ + -PlibtmuxJavaVersion=${{ matrix.java }} \ + -PlibtmuxClojureVersion=${{ matrix.clojure }} \ + "-PlibtmuxTmuxBinary=$HOME/tmux-builds/$LANE/bin/tmux" \ + "-PlibtmuxTmuxExpected=$LANE" + + - name: No tmux server outlived the suite + if: always() + run: | + left="$(pgrep -c tmux || true)" + if [ "${left:-0}" -ne 0 ]; then + echo "::error::$left tmux server(s) survived the suite" + for p in $(pgrep tmux); do tr '\0' ' ' < "/proc/$p/cmdline"; echo; done + exit 1 + fi + echo "no tmux server survived the suite" + + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: reports-clojure-tmux-${{ matrix.tmux }}-jdk${{ matrix.java }}-${{ matrix.clojure }} + path: '**/build/reports/**' + retention-days: 7 + + macos: + name: Clojure macOS tmux ${{ matrix.tmux }} (JDK ${{ matrix.java }}, Clojure ${{ matrix.clojure }}) + runs-on: macos-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + java: ['21', '25'] + clojure: ['1.12.4', '1.12.6'] + tmux: ['3.2a', '3.7c'] + steps: + - uses: actions/checkout@v7 + + - name: Build tmux dependencies + run: brew install libevent ncurses bison pkg-config + + - name: Restore tmux ${{ matrix.tmux }} + id: tmux-cache + uses: actions/cache@v6 + with: + path: ~/tmux-builds/${{ matrix.tmux }} + key: tmux-${{ matrix.tmux }}-${{ runner.os }}-${{ runner.arch }} + + - name: Build tmux ${{ matrix.tmux }} + if: steps.tmux-cache.outputs.cache-hit != 'true' + env: + LANE: ${{ matrix.tmux }} + run: | + set -eux + work="$(mktemp -d)" + curl -fsSL -o "$work/tmux.tar.gz" \ + "https://github.com/tmux/tmux/releases/download/$LANE/tmux-$LANE.tar.gz" + tar -xzf "$work/tmux.tar.gz" -C "$work" + cd "$work/tmux-$LANE" + libevent_prefix="$(brew --prefix libevent)" + ncurses_prefix="$(brew --prefix ncurses)" + export PKG_CONFIG_PATH="$libevent_prefix/lib/pkgconfig:$ncurses_prefix/lib/pkgconfig" + export CPPFLAGS="-I$ncurses_prefix/include" + export LDFLAGS="-L$ncurses_prefix/lib" + ./configure --prefix="$HOME/tmux-builds/$LANE" + make -j"$(sysctl -n hw.logicalcpu)" + make install + + - name: The lane runs the tmux it is named after + env: + LANE: ${{ matrix.tmux }} + run: | + "$HOME/tmux-builds/$LANE/bin/tmux" -V + + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: ${{ matrix.java }} + + - uses: gradle/actions/setup-gradle@v6 + with: + validate-wrappers: true + + - name: Run the native Clojure smoke and lifecycle suite + env: + LANE: ${{ matrix.tmux }} + run: | + ./gradlew :integration-tests:clojureIntegrationTest --rerun-tasks --stacktrace \ + -PlibtmuxJavaVersion=${{ matrix.java }} \ + -PlibtmuxClojureVersion=${{ matrix.clojure }} \ + "-PlibtmuxTmuxBinary=$HOME/tmux-builds/$LANE/bin/tmux" \ + "-PlibtmuxTmuxExpected=$LANE" + + - name: No tmux server outlived the suite + if: always() + run: | + left="$(pgrep -c tmux || true)" + if [ "${left:-0}" -ne 0 ]; then + echo "::error::$left tmux server(s) survived the suite" + for p in $(pgrep tmux); do ps -o command= -p "$p"; done + exit 1 + fi + echo "no tmux server survived the suite" + + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: reports-clojure-macos-${{ matrix.tmux }}-jdk${{ matrix.java }}-${{ matrix.clojure }} + path: '**/build/reports/**' + retention-days: 7 diff --git a/integration-tests/build.gradle.kts b/integration-tests/build.gradle.kts index a8d91b57..41e05bae 100644 --- a/integration-tests/build.gradle.kts +++ b/integration-tests/build.gradle.kts @@ -38,6 +38,7 @@ val clojureIntegrationTest = classpath = sourceSets.test.get().runtimeClasspath mainClass.set("clojure.main") args("-m", "libtmux.internal.test-runner", "test") + systemProperty("libtmux.clojure.expected", integrationClojureVersion) val namespaces = provider { fileTree("src/test/clojure") { include("**/*_test.clj") } diff --git a/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj b/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj index 0cdfdd0f..e0f4deec 100644 --- a/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj +++ b/integration-tests/src/test/clojure/libtmux/internal/fixture_test.clj @@ -166,6 +166,12 @@ (System/setProperty property previous) (System/clearProperty property)))))) +(deftest selected-clojure-version-must-match + (let [expected (System/getProperty "libtmux.clojure.expected")] + (is (some? expected) "the integration runner must name its Clojure runtime") + (is (= expected (clojure-version)) + "the integration runner must use the Clojure version it names"))) + (deftest changed-process-identity-is-not-owned (let [process (ProcessHandle/current) owned {:process process