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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,20 @@ object LeakedServers {
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 (stop(tmux, directory, socket)) stopped else stuck).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())
}

/** Tells the server on [socket] to stop and waits for it to; false if it still answers. */
fun stop(tmux: String, tmpdir: Path, socket: Path): Boolean {
kill(tmux, tmpdir, socket)
return !stillAnswers(socket)
}

// 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
Expand Down Expand Up @@ -192,6 +197,7 @@ extra["quarantineTmux"] = { run: TaskProvider<JavaExec> ->
run.configure { quarantine() }
checkAfter(run.name)
}
extra["stopServer"] = { directory: Path, tmux: String, socket: Path -> LeakedServers.stop(tmux, directory, socket) }
extra["stopLeakedServers"] = { directory: Path, tmux: String ->
LeakedServers.sweep(directory, tmux).let { it.stopped to it.stuck }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class ModuleTestsQuarantineTest {
assertTrue("libtmux.tmux-quarantine.gradle.kts" in text, "module-tests/cli does not apply the quarantine")
assertTrue("withoutServerSelectors(this)" in text, "module-tests/cli does not scrub its children")
assertTrue("environment(\"TMUX_TMPDIR\"" in text, "module-tests/cli gives its children no TMUX_TMPDIR")
assertTrue("stopServer(" in text, "module-tests/cli does not await the server it started")
assertTrue("stopLeakedServers(" in text, "module-tests/cli does not stop the servers its children leave")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,25 @@ class TmuxQuarantineTest {
}
}

@Test
fun `a server still exiting after kill-server is awaited, not swept as a leak`(@TempDir project: Path) {
requireTmux()
val slow = slowKillServer(project)
val pidFile = project.resolve("server.pid")
writeAwaitProbe(project, slow, socketRoot.resolve("await"), pidFile)

try {
val output = run(project).text

assertTrue(
"PROBE swept own=true stopped=0 stuck=0" in output,
"the sweep took a server that was exiting for a leak",
)
} finally {
stopServer(pidFile)
}
}

@Test
fun `temporary files a cancelled run left are cleared before the next run`(@TempDir project: Path) {
writeProbe(project, applyPlugin = true, optIn = true)
Expand Down Expand Up @@ -320,6 +339,49 @@ class TmuxQuarantineTest {
return noop
}

// A tmux whose kill-server returns at once and takes effect 0.4 s later, as a server still exiting looks.
private fun slowKillServer(project: Path): Path {
val slow = Files.writeString(
project.resolve("tmux-slow"),
"#!/bin/sh\nfor a in \"$@\"; do\n if [ \"\$a\" = kill-server ]; then\n" +
" ( sleep 0.4; exec ${realTmux()} \"$@\" ) >/dev/null 2>&1 &\n exit 0\n fi\ndone\n" +
"exec ${realTmux()} \"$@\"\n",
)
Files.setPosixFilePermissions(slow, java.nio.file.attribute.PosixFilePermissions.fromString("rwx------"))
return slow
}

/** A build whose task starts a server, stops it as the cli build does, and sweeps the directory it lives in. */
private fun writeAwaitProbe(project: Path, tmux: Path, directory: Path, pidFile: Path) {
Files.writeString(project.resolve("settings.gradle.kts"), "rootProject.name = \"probe\"\n")
Files.writeString(
project.resolve("build.gradle.kts"),
"""
import java.nio.file.Files
import java.nio.file.Path

plugins { id("libtmux.tmux-quarantine") }
@Suppress("UNCHECKED_CAST")
val stopServer = extra["stopServer"] as (Path, String, Path) -> Boolean
@Suppress("UNCHECKED_CAST")
val sweep = extra["stopLeakedServers"] as (Path, String) -> Pair<List<String>, List<String>>
tasks.register("probe") {
doLast {
val directory = Files.createDirectories(Path.of("$directory"))
val socket = directory.resolve("s")
val start = listOf("$tmux", "-S", socket.toString(), "-f", "/dev/null")
ProcessBuilder(start + listOf("new-session", "-d", "-s", "x", "cat")).inheritIO().start().waitFor()
val ask = ProcessBuilder(start + listOf("display-message", "-p", "#{pid}")).start()
Files.writeString(Path.of("$pidFile"), String(ask.inputStream.readAllBytes()).trim())
val awaited = stopServer(directory, "$tmux", socket)
val (stopped, stuck) = sweep(directory, "$tmux")
println("PROBE swept own=" + awaited + " stopped=" + stopped.size + " stuck=" + stuck.size)
}
}
""".trimIndent(),
)
}

/** A build whose Test task starts a server on a socket in the JVM's temporary directory and leaves it. */
private fun writeTestTaskProbe(project: Path, tmux: Path, pidFile: Path) {
val classes = Files.createDirectories(project.resolve("classes"))
Expand Down
8 changes: 4 additions & 4 deletions module-tests/cli/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ apply(from = rootProject.file("../../build-logic/conventions/src/main/kotlin/lib
@Suppress("UNCHECKED_CAST")
val withoutServerSelectors = extra["withoutServerSelectors"] as (ProcessForkOptions) -> Unit
@Suppress("UNCHECKED_CAST")
val stopServer = extra["stopServer"] as (Path, String, Path) -> Boolean
@Suppress("UNCHECKED_CAST")
val stopLeakedServers = extra["stopLeakedServers"] as (Path, String) -> Pair<List<String>, List<String>>

// The version the root build staged. Required, so a launcher that resolved some other release
Expand Down Expand Up @@ -56,10 +58,8 @@ tasks.register("run") {
failure = thrown
throw thrown
} finally {
providers.exec {
commandLine("tmux", "-S", socket, "kill-server")
isIgnoreExitValue = true
}.result.get()
// kill-server returns before the server has exited; the sweep must not take it for a leak.
stopServer(directory, "tmux", Path.of(socket))
val (stopped, stuck) = stopLeakedServers(directory, "tmux")
// Kept while a server still answers, so it can be found and ended.
if (stuck.isEmpty()) directory.toFile().deleteRecursively()
Expand Down
Loading