Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
fa70c60
Build(refactor[quarantine]): Extract the plugin
tony Oct 31, 2026
834f8cf
Build(feat[quarantine]): Let JavaExec runs opt in
tony Oct 31, 2026
4f43400
Build(feat[quarantine]): Stop servers tasks leave
tony Oct 31, 2026
50239fb
Build(fix[quarantine]): Cover libtmux-kotlin
tony Oct 31, 2026
754ca10
Build(fix[quarantine]): Cover module-tests
tony Oct 31, 2026
5905cb9
Build(feat[quarantine]): Refuse unquarantined runs
tony Oct 31, 2026
df71049
Build(feat[quarantine]): Gate modules with tests
tony Oct 31, 2026
d935074
Build(test[quarantine]): Add a bare-client canary
tony Oct 31, 2026
7e99eb2
Examples(fix[quarantine]): Sandbox the API runner
tony Oct 31, 2026
0ab46ab
Build(docs[quarantine]): Describe the quarantine
tony Oct 31, 2026
1513096
Build(fix[quarantine]): Sweep the JVM's tempdir
tony Nov 1, 2026
5637c12
Build(test[quarantine]): Pin the cap and listener
tony Nov 1, 2026
d9cb629
Docs(docs[changelog]): Record the test sandbox
tony Nov 1, 2026
44c32a5
Build(fix[quarantine]): Clear cancelled-run debris
tony Nov 1, 2026
5a78e57
Build(test[quarantine]): Pin the module gate
tony Nov 1, 2026
c99c757
Build(fix[quarantine]): Name entries, not paths
tony Nov 1, 2026
4f9c8ae
Build(test[quarantine]): Pin the Test finalizer
tony Nov 1, 2026
155327e
Build(test[quarantine]): Do not skip the canary
tony Nov 1, 2026
a697c20
Build(refactor[quarantine]): Narrow SandboxCheck
tony Nov 1, 2026
1f4a14a
Build(docs[quarantine]): Trim the plugin comments
tony Nov 1, 2026
d306c2e
Docs(docs[quarantine]): Correct the sandbox docs
tony Nov 1, 2026
9aad500
Build(test[quarantine]): Fail on a misnamed entry
tony Nov 1, 2026
3c20d68
Build(test[quarantine]): Pin module-tests wiring
tony Nov 1, 2026
be8b780
Build(test[quarantine]): Need tmux, pin freshness
tony Nov 1, 2026
14f0075
Build(test[quarantine]): Pin TempDir lifetime
tony Nov 1, 2026
c6e4b78
Build(fix[quarantine]): Drop launcher output
tony Nov 1, 2026
39ba98c
Build(style[quarantine]): Wrap lines, cut comments
tony Nov 1, 2026
1f573d9
Docs(docs[quarantine]): Document API_RUN_ROOT
tony Nov 1, 2026
10b7ab5
Build(fix[quarantine]): Order-proof the canary
tony Nov 1, 2026
8c479cd
Build(test[quarantine]): Keep probes in the root
tony Nov 1, 2026
e5e608e
Build(test[quarantine]): Shorten the runner root
tony Nov 1, 2026
f9ec92f
Build(fix[quarantine]): Keep the run's failure
tony Nov 1, 2026
ad9a64b
Docs(docs[quarantine]): Fix the finalizer note
tony Nov 1, 2026
e4961f2
Build(fix[quarantine]): Sweep the cli run dir
tony Nov 1, 2026
b1a7266
Build(docs[quarantine]): Note wiring test limits
tony Nov 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,46 @@ port.** Use one of:
Never `/tmp/libtmux-…` on its own, and never the default socket. `libtmux-` is
the prefix a sibling port is also using, which is the whole problem.

A client that names no socket follows `TMUX` to the server you are working in,
and otherwise lands on the default socket. So every `Test` task of a library
module, and each `run` in the builds under [`module-tests/`](../module-tests/),
starts without `TMUX`, `TMUX_PANE` and `LIBTMUX_SOCKET*`, and with a private
`TMUX_TMPDIR` of its own under `/tmp/libtmux-java-test/`. The convention plugin
`libtmux.tmux-quarantine` does this for every module, and the module-tests
builds apply the same file by path and opt their runs in. `module-tests/cli`
starts its children from a task action, with the same scrub and a
`TMUX_TMPDIR` of its own. The tests of `build-logic` are outside the gate: they
start tmux only inside probe builds that apply the plugin, or on a socket they
name.

Development runs are not quarantined. `runDevelopment`, the `run` tasks of
`libtmux-mcp` and `mcp-swap`, `scalaModeBenchmark` and every other `JavaExec`
task keep the environment Gradle was started in, so they follow your `TMUX`.
Pass `-S` to aim one elsewhere.

A quarantined task's JVM also gets the quarantine as its `java.io.tmpdir`, and a
`Test` task keeps each `@TempDir` until the finalizer has looked, so a socket a
test names under a temporary directory is inside it too.

When a quarantined task ends, even by failing, its `<task>QuarantineCheck`
finalizer looks for sockets anywhere in the task's quarantine, without following
links, and stops the server on each one that answers. It fails the build naming
them even when the tests passed. It keeps the directory, and says so, while a
server still answers after `kill-server`. A socket file nothing answers on is
not a leak. `examples/api/run.sh` does the same for the program it starts.

The finalizer cannot see a server whose socket lies elsewhere. A
[`TmuxExtension`](../libtmux-junit5/) fixture, and the Scala `OwnedTmux` beside
it, make a directory of their own under `/tmp/libtmux-java-test/`, named for the
JVM that owns it. So do a few tests that name a path under that root. All of
them end their servers themselves, and the finalizer does not check that they
did.

The test JVM also checks the quarantine before its first test and refuses to
start when it was not launched that way, which is what happens to a run from an
IDE. Run tests through `./gradlew`. `./gradlew everyTestModuleIsQuarantined`
fails when a module with tests lacks the plugin or the check.

Sibling libtmux ports — Python, Go, Rust, TypeScript, C#, C++, Swift — are
worked on in parallel on the same machine, and every one of them starts real
tmux servers in `/tmp`. Their debris outlives their test runs: servers from an
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ release is supported, and there are no backports.

## Unreleased

### Development

- **Test runs stay away from the tmux server a developer works in.** Since
#117 a server handle with no socket lets tmux pick the server, which is the
one in `TMUX` or the default socket, so a test that forgot its socket could
kill the developer's own session. Every `Test` task of a library module, and
each `run` in the `module-tests` builds, now starts without `TMUX`,
`TMUX_PANE` and `LIBTMUX_SOCKET*`, in a private `TMUX_TMPDIR`. A test JVM
uses that directory as its temporary directory, and refuses to start when it
was not launched that way, as an IDE run is not. When a task ends, a
finalizer stops any server on a socket in the directory and fails the build
naming it. `examples/api/run.sh` does the same for the program it starts.
(#123)

## 0.1.1 — 2026-11-01

### Changed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
// Shared Java conventions. A module script then declares only what makes it different.
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.Path
import java.security.MessageDigest
import java.util.HexFormat
import net.ltgt.gradle.errorprone.errorprone

plugins {
`java-library`
id("net.ltgt.errorprone")
id("com.diffplug.spotless")
id("libtmux.tmux-quarantine")
}

repositories { mavenCentral() }
Expand Down Expand Up @@ -38,6 +33,8 @@ dependencies {
testImplementation(platform(libs.findLibrary("junit-bom").orElseThrow()))
testImplementation(libs.findLibrary("junit-jupiter").orElseThrow())
testRuntimeOnly(libs.findLibrary("junit-platform-launcher").orElseThrow())
// Refuses a test run that could reach a tmux server it was not given.
testRuntimeOnly("io.github.libtmux.build:test-sandbox")
}

tasks.withType<JavaCompile>().configureEach {
Expand Down Expand Up @@ -126,64 +123,6 @@ tasks.withType<Test>().configureEach {
listOfNotNull(binary?.takeIf(File::isFile))
}).withPropertyName("tmuxBinary").withPathSensitivity(PathSensitivity.NONE)

// Quarantine every test from the developer's own tmux. A test is supposed to pass an explicit
// -S, but nothing in the language enforces that, and a command that omits it silently addresses
// a real server and can kill it. Two environment values decide where a bare client lands: tmux
// resolves its default socket under TMUX_TMPDIR when it execs, and $TMUX takes precedence over
// that for a client started inside a pane — which the Gradle daemon may well have been.
// TMUX_TMPDIR is set per invocation in doFirst below, under the same root as the named sockets.
environment.remove("TMUX")
environment.remove("TMUX_PANE")

// Where a test puts the sockets it names explicitly, which TMUX_TMPDIR above does not govern.
// Sibling libtmux ports run on this machine at the same time and start their own tmux servers
// under /tmp; a server of theirs left behind by an exited run holds a pty and answers to a name
// this suite might have chosen, which turns their debris into this suite's intermittent
// failures. So this port takes a root of its own, and everything a test creates lands under it.
//
// Short on purpose: a unix socket path cannot exceed about 104 bytes, which rules out the build
// directory and is why this is not derived from one.
val socketRoot = providers.gradleProperty("libtmuxSocketRoot").getOrElse("/tmp/libtmux-java-test")
val checkoutIdentity = rootProject.rootDir.canonicalPath
// This task's own path, :module:test, so each test task in one invocation has a quarantine of its
// own and one module's leftover socket cannot fail the next module's tests.
val taskIdentity: String = path
systemProperty("java.io.tmpdir", socketRoot)
doFirst {
require(socketRoot.length <= 40) {
"libtmuxSocketRoot is $socketRoot, too long to leave room for a socket under it"
}
// The quarantine shares the configured root, so overriding libtmuxSocketRoot moves the
// bare-client sockets along with the named ones instead of splitting them across two roots.
// Owner identity separates concurrent invocations; 16 hex digits leave AF_UNIX room.
val quarantineIdentity = listOf(
checkoutIdentity,
taskIdentity,
ProcessHandle.current().pid().toString(),
).joinToString("\u0000")
val quarantineDigest = MessageDigest.getInstance("SHA-256")
.digest(quarantineIdentity.toByteArray(StandardCharsets.UTF_8))
val quarantineName = HexFormat.of().formatHex(quarantineDigest, 0, 8)
val tmuxTmpDir = Path.of(socketRoot, quarantineName)

if (Files.exists(tmuxTmpDir, LinkOption.NOFOLLOW_LINKS)) {
require(Files.isDirectory(tmuxTmpDir, LinkOption.NOFOLLOW_LINKS)) {
"tmux quarantine is not a directory: $tmuxTmpDir"
}
val entries = Files.walk(tmuxTmpDir).use { paths -> paths.toList() }
val stale = entries.firstOrNull {
it != tmuxTmpDir && !Files.isDirectory(it, LinkOption.NOFOLLOW_LINKS)
}
require(stale == null) {
"tmux quarantine contains a stale entry: $stale"
}
entries.asReversed().filter { it != tmuxTmpDir }.forEach(Files::delete)
}
Files.createDirectories(tmuxTmpDir)
environment("TMUX_TMPDIR", tmuxTmpDir.toString())
File(socketRoot).mkdirs()
}

testLogging {
events("failed")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Fails when a module with Test tasks lacks the tmux quarantine or the JVM-side refusal.
// No plugins block, so the root build applies this file by path.
tasks.register("everyTestModuleIsQuarantined") {
group = "verification"
description = "Fails when a module with test tasks lacks the tmux quarantine or the refusal."

val offenders = provider {
subprojects
.filter { it.tasks.withType(Test::class.java).isNotEmpty() }
.filter { candidate ->
val refusal = candidate.configurations.findByName("testRuntimeOnly")?.dependencies?.any {
it.group == "io.github.libtmux.build" && it.name == "test-sandbox"
} ?: false
!candidate.pluginManager.hasPlugin("libtmux.tmux-quarantine") || !refusal
}
.map { it.path }
}

doLast {
val found = offenders.get()
require(found.isEmpty()) {
"these run tests outside the tmux quarantine: ${found.joinToString(", ")}"
}
logger.lifecycle("every module with tests runs them under the tmux quarantine")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// Keeps every Test task, and each JavaExec task that calls quarantineTmux, off the developer's own tmux.
// No plugins block, so module-tests, which resolve nothing from this build, apply this file by path.
import java.io.IOException
import java.net.StandardProtocolFamily
import java.net.UnixDomainSocketAddress
import java.nio.channels.SocketChannel
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.Path
import java.nio.file.attribute.BasicFileAttributes
import java.nio.file.attribute.PosixFilePermissions
import java.security.MessageDigest
import java.util.HexFormat
import java.util.concurrent.TimeUnit

// One root for every task's quarantine; short, because a unix socket path cannot exceed about 104 bytes.
val socketRoot = providers.gradleProperty("libtmuxSocketRoot").getOrElse("/tmp/libtmux-java-test")
val checkoutIdentity = rootProject.rootDir.canonicalPath

/** The variables that choose a server for a client that names none. */
object ServerSelectors {
fun select(name: String) = name == "TMUX" || name == "TMUX_PANE" || name.startsWith("LIBTMUX_SOCKET")

// Replaced rather than edited: removing from the map a providers.javaexec spec returns does not
// reach the child.
fun clear(options: ProcessForkOptions) {
options.setEnvironment(options.environment.filterKeys { !select(it) })
}
}

/** Where a task's quarantine lives, which the root, the checkout, the task and this daemon decide. */
object QuarantineLocation {
fun of(root: String, checkout: String, taskPath: String): Path {
val identity = listOf(checkout, taskPath, ProcessHandle.current().pid().toString()).joinToString("\u0000")
val digest = MessageDigest.getInstance("SHA-256").digest(identity.toByteArray(StandardCharsets.UTF_8))
return Path.of(root, HexFormat.of().formatHex(digest, 0, 8))
}
}

/** What a quarantined task left behind: servers it started and never stopped. */
object LeakedServers {
/**
* @param stopped the sockets whose server answered and then stopped answering
* @param stuck the sockets whose server still answers after kill-server
*/
class Swept(val stopped: List<String>, val stuck: List<String>)

/** Stops every server answering on a socket under [directory], and removes it unless one still does. */
fun sweep(directory: Path, tmux: String): Swept {
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) return Swept(emptyList(), emptyList())
val sockets = Files.walk(directory).use { paths -> paths.filter(::isSocket).toList() }
val stopped = mutableListOf<String>()
val stuck = mutableListOf<String>()
for (socket in sockets.filter(::answers)) {
kill(tmux, directory, socket)
(if (stillAnswers(socket)) stuck else stopped).add(directory.relativize(socket).toString())
}
if (stuck.isEmpty()) {
Files.walk(directory).use { paths -> paths.sorted(Comparator.reverseOrder()).forEach(Files::delete) }
}
return Swept(stopped.sorted(), stuck.sorted())
}

// A socket file itself: a link to one is not, and is left for whoever owns what it points at.
private fun isSocket(path: Path) =
Files.readAttributes(path, BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS).isOther

private fun answers(socket: Path) = try {
SocketChannel.open(StandardProtocolFamily.UNIX).use { it.connect(UnixDomainSocketAddress.of(socket)) }
true
} catch (nothingListens: IOException) {
false
} catch (tooLong: IllegalArgumentException) {
false
}

// A server can take a moment to go once it has been told to.
private fun stillAnswers(socket: Path): Boolean {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3)
while (answers(socket)) {
if (System.nanoTime() > deadline) return true
Thread.sleep(50)
}
return false
}

private fun kill(tmux: String, tmpdir: Path, socket: Path) {
val client = ProcessBuilder(tmux, "-S", socket.toString(), "kill-server")
.redirectErrorStream(true)
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
client.environment().keys.removeIf(ServerSelectors::select)
client.environment()["TMUX_TMPDIR"] = tmpdir.toString()
val process = try {
client.start()
} catch (cannotStart: IOException) {
return
}
if (!process.waitFor(15, TimeUnit.SECONDS)) process.destroyForcibly()
}
}

/** Fails the build when a quarantined task left a server running, after stopping it. */
class CheckQuarantine(
private val root: String,
private val checkout: String,
private val taskPath: String,
private val tmux: Provider<String>,
) : Action<Task> {
override fun execute(finalizer: Task) {
val swept = LeakedServers.sweep(QuarantineLocation.of(root, checkout, taskPath), tmux.get())
if (swept.stuck.isNotEmpty()) {
throw GradleException(
"$taskPath left a tmux server running: ${(swept.stopped + swept.stuck).sorted().joinToString(", ")}; " +
"${swept.stuck.joinToString(", ")} still answers after kill-server, so its quarantine was kept",
)
}
if (swept.stopped.isNotEmpty()) {
throw GradleException("$taskPath left a tmux server running: ${swept.stopped.joinToString(", ")}")
}
}
}

/** Gives a task its quarantine as it starts, from plain values so the configuration cache can store it. */
class PrepareQuarantine(private val root: String, private val checkout: String) : Action<Task> {
private val ownerOnly = PosixFilePermissions.fromString("rwx------")

override fun execute(task: Task) {
val directory = directoryFor(task.path)
val options = task as JavaForkOptions
ServerSelectors.clear(options)
options.environment("TMUX_TMPDIR", directory.toString())
// Sockets a test names under its temporary directory are inside the swept tree.
options.systemProperty("java.io.tmpdir", directory.toString())
if (task is Test) {
// What the test JVM's refusal compares TMUX_TMPDIR with.
task.systemProperty("libtmux.test.sandbox", directory.toString())
// -S cannot reach a server whose socket JUnit removed, so @TempDirs stay until the finalizer looks.
task.systemProperty("junit.jupiter.tempdir.cleanup.mode.default", "never")
}
}

private fun directoryFor(taskPath: String): Path {
require(root.length <= 40) { "libtmuxSocketRoot is too long to leave room for a socket under it" }
val directory = QuarantineLocation.of(root, checkout, taskPath)

Files.createDirectories(Path.of(root))
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
require(Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
"tmux quarantine ${directory.fileName} is not a directory"
}
val entries = Files.walk(directory).use { paths -> paths.toList() }
// A socket may still have a server behind it, and deleting it would hide that server. A plain
// file is debris, such as the temporary files of a run that was cancelled.
val stale = entries.firstOrNull {
it != directory &&
!Files.isDirectory(it, LinkOption.NOFOLLOW_LINKS) &&
!Files.isRegularFile(it, LinkOption.NOFOLLOW_LINKS)
}
require(stale == null) { "tmux quarantine contains a stale entry: ${stale?.let(directory::relativize)}" }
entries.asReversed().filter { it != directory }.forEach(Files::delete)
Files.setPosixFilePermissions(directory, ownerOnly)
} else {
Files.createDirectory(directory, PosixFilePermissions.asFileAttribute(ownerOnly))
}
return directory
}
}

fun <T> T.quarantine() where T : Task, T : JavaForkOptions {
ServerSelectors.clear(this)
doFirst(PrepareQuarantine(socketRoot, checkoutIdentity))
}

// Registered from the build script and not while a task is being created, which Gradle refuses. The
// binary is read when the finalizer starts, because a matrix lane names it after this file has run.
fun checkAfter(taskName: String) {
val taskPath = if (path == ":") ":$taskName" else "$path:$taskName"
val configured = providers.gradleProperty("libtmuxTmux")
val tmux = tasks.named(taskName).map {
(it as? Test)?.systemProperties?.get("libtmux.tmux")?.toString() ?: configured.orNull ?: "tmux"
}
val check = tasks.register("${taskName}QuarantineCheck") {
doLast(CheckQuarantine(socketRoot, checkoutIdentity, taskPath, tmux))
}
tasks.named(taskName) { finalizedBy(check) }
}

// For a process that is not a task, such as a providers.javaexec run from a task action.
extra["withoutServerSelectors"] = { options: ProcessForkOptions -> ServerSelectors.clear(options) }
extra["quarantineTmux"] = { run: TaskProvider<JavaExec> ->
run.configure { quarantine() }
checkAfter(run.name)
}
extra["stopLeakedServers"] = { directory: Path, tmux: String ->
LeakedServers.sweep(directory, tmux).let { it.stopped to it.stuck }
}

tasks.withType<Test>().configureEach { quarantine() }
afterEvaluate { tasks.withType<Test>().names.forEach(::checkAfter) }
Loading
Loading