Skip to content

Sandbox: Keep test runs away from the developer's tmux server - #123

Merged
tony merged 35 commits into
masterfrom
test/sandbox-default-server
Nov 1, 2026
Merged

tony merged 35 commits into
masterfrom
test/sandbox-default-server

Conversation

@tony

@tony tony commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Closes #121
Part of #111

Since #117 a Server with no socket lets tmux pick the server, which from a terminal is the one named by TMUX, and otherwise the default socket. A test, doctest or example that forgets its socket now reaches the server the developer is working in, and a run that crashes leaves servers behind. The guard has to be in place before anything relies on that default, so every run is cut off from that server and a server a run leaves is found and stopped.

What it does

Every Test task and each opted-in JavaExec run goes through libtmux.tmux-quarantine, applied to every module by libtmux.java-library and by path to the module-tests builds. quarantine() clears the selectors from the task's environment, and PrepareQuarantine makes the task's quarantine as it starts: a fresh 0700 directory under /tmp/libtmux-java-test/ whose name is a digest of the checkout, the task path and the daemon, exported as TMUX_TMPDIR and also given to the JVM as java.io.tmpdir. The test JVM then runs SandboxSessionListener, which refuses the run when SandboxCheck.findings reports a selector, no quarantine, or a TMUX_TMPDIR that is not the one the build made. everyTestModuleIsQuarantined fails check when a module with tests lacks the plugin or the listener. When the task ends, even by failing, CheckQuarantine runs LeakedServers.sweep: it stops each server that answers on a socket anywhere under the quarantine with -S, checks that it stopped, fails the build naming the sockets, and removes the directory only when nothing still answers. examples/api/run.sh does the same for the program the API examples start. Development runs (run, runDevelopment, the benchmarks) keep the caller's environment on purpose, as before.

Bare tests, doctests and examples are covered because the guard is on the task, not on the test: DocumentedCommandsTest, ReadmeExamplesTest and the other documentation tests are Test tasks, the API examples run through run.sh, and the module-tests runs opt in. The canary below builds a Server with no endpoint, which is the new risk #117 created.

Where the first branch fell short

The branch I started from claimed a whole-tree scan, and a probe showed it was not. The finalizer swept only the quarantine, but tests name their sockets under java.io.tmpdir, which was the root every task shares. A test that left a server on a @TempDir socket passed the build, and JUnit then deleted the socket, so the server lived on with no path to it. Probe on a real Test task before the fix: BUILD SUCCESSFUL, one tmux server still running, stopped by hand by its pid. After the fix: BUILD FAILED, :libtmux-junit5:test left a tmux server running: junit-13350919600370463308/s, and the server is gone. Two settings make it hold: java.io.tmpdir is the quarantine, and a Test task keeps each @TempDir until the finalizer has looked, because -S cannot reach a server whose socket is gone.

The six points of the sandbox contract

Each break was applied alone to the committed code and the named test run, then restored. The first column is the point, the second the break, the third what failed.

# Break Failing test
1 Selectors cleared ServerSelectors.select matches nothing a JavaExec task that opts in has no selector and a private TMUX_TMPDIR of its own
2 Private, owner-only, short 0755 instead of 0700 the same test
2 no TMUX_TMPDIR exported the same test
2 path cap raised from 40 to 400 characters a socket root too long to leave room for a socket is refused before the task runs
2 Fresh an existing quarantine is not cleared before the task, then, one at a time, plain files refused and a socket cleared without a word temporary files a cancelled run left are cleared before the next run, a socket a cancelled run left is refused, not silently cleared
3 Refuses unsandboxed findings returns nothing seven SandboxCheckTest cases and the two refusal cases of SandboxSessionListenerTest
3 the listener does not call require aSessionOpenedInAPaneIsRefused, aSessionOpenedWithNoQuarantineIsRefused
3 the ServiceLoader entry removed theLauncherLoadsTheListenerOnItsOwn
3 the gate never flags a module a module with tests and no quarantine plugin fails the build and is named, a module with tests and no refusal fails the build and is named
3 libtmux-kotlin without the plugin, then without the listener, in this repository everyTestModuleIsQuarantined, naming :libtmux-kotlin both times
1, 2 module-tests the java run is not opted in; the cli children are not scrubbed the java, kotlin and scala builds apply the quarantine and opt their run in, the cli build scrubs its children, gives them a TMUX_TMPDIR and stops what they leave
4 Bare-client canary a bare handle adds -S outside the quarantine aBareClientReachesNoServerOutsideTheQuarantine
4 the canary reads the wrong socket aRealBareClientStartsItsServerInsideTheQuarantine, aRealClientThatEscapedTheQuarantineIsNotSeenInsideIt, and the finalizer names the two servers it stopped
5 Whole-tree scan java.io.tmpdir left alone theQuarantineIsADirectoryAndTheJvmsTemporaryDirectory, a server on a socket in the temporary directory the JVM was given is found too
5 @TempDir removed with its test tempDirsOutliveTheirTestsSoTheFinalizerCanLookInThem, which fails the class
5 the walk stops at depth 1 a server on a socket in a subdirectory of the quarantine is found too, a quarantined task that leaves a server running fails the build and the server is stopped
5 Test tasks are not finalized, or the finalizer ignores the libtmux.tmux property a Test task is finalized by the check, with the tmux its libtmux tmux property names
5 the directory is removed while a server still answers the quarantine is kept and the build fails when kill-server does not stop the server
5 the stop is not verified the same test
5 a leak does not fail the run a quarantined task that leaves a server running fails the build and the server is stopped, the check still runs when the task itself fails
6 a stale entry is named by its full path a socket a cancelled run left is refused, not silently cleared
6 Names and booleans a finding prints the variable's value noFindingAndNoRefusalPrintsAValue, everySocketSelectorIsReportedByName

The new tests that cover points 2, 3 and 5 are in TmuxQuarantineTest, SandboxSessionListenerTest and SandboxCanaryTest. Failure messages in them name variables and state what was expected, and none prints an environment value.

Checked

./gradlew check --continue --max-workers=4 through heavy.sh --mem 10G, from the commit that moved java.io.tmpdir (its code is identical to 15130969), on tmux 3.8a, took 7 minutes. Every *QuarantineCheck finalizer passed, everyTestModuleIsQuarantined passed, and one test failed: ServerTest.killingThroughAnotherTransportWaitsOnNoLocalProcess. It copies /bin/sleep to tmux-lookalike, and on this host (Ubuntu 26.04) /bin/sleep is a link to the uutils multicall binary, so the copy exits at once with unknown program 'tmux-lookalike'. That has nothing to do with the guard, and I did not run it on master. CI on its own runners is the judge.

Afterwards I compared the tmux servers listening before and after the run. They were the same five, none from this run, and none under /tmp/libtmux-java-test or /tmp/libtmux-java-dev. The later commits were run on what they change: build-logic check (TmuxQuarantineTest 15 cases, QuarantineGateTest, SandboxSessionListenerTest, SandboxCheckTest), then SandboxCanaryTest, everyTestModuleIsQuarantined, examples:test, docs:test and libtmux-junit5:check on the root build.

Servers a TmuxExtension fixture or the Scala OwnedTmux starts live in a directory of their own under /tmp/libtmux-java-test/, named for the owning JVM, and about ten tests name a path directly under that root. The finalizer cannot see either. They end their servers themselves, the finalizer does not check that they did, and none was left by the run above. .github/CONTRIBUTING.md says so.

Judgement

  • The quarantine is one directory per task and daemon, and the same directory is TMUX_TMPDIR and java.io.tmpdir. A directory per test would need every test to cooperate.
  • A run cancelled in a daemon that survives leaves files in the quarantine. The next run of that task in that daemon clears plain files and refuses a socket by name, because a socket may have a server behind it.
  • tools/reap-stale-servers.sh is unchanged. It reaps fixture directories named for a pid; quarantine directories are hashed and removed by their own finalizer, so it cannot tell a live daemon's from a dead one's.
  • The changelog entry sits under Development, since no caller of the library sees the change.
  • PaneOperationsIntegrationTest.theUnnamedBreakIsNamedWhatTmuxWouldHaveNamedIt was recorded as a flake in the queue. It passed in the full run and 150 times in a row here, and none of the nine failed CI or tmux matrix runs among the last 300 mentions it, so no issue is filed.

tony added 10 commits November 1, 2026 06:37
why: The quarantine lived inside libtmux.java-library's Test
configuration, so a module that did not apply that convention could not
have it, and nothing but a copy could give it to one. libtmux-kotlin
applies no java-library and runs its tests with the caller's TMUX. A
convention of its own can be applied by id, by every module that runs
tests.

what:
- Move the Test quarantine into libtmux.tmux-quarantine unchanged
- Apply it from libtmux.java-library, so every module that applied the
  quarantine before still gets it
why: Only Test tasks were quarantined, and nothing cleared
LIBTMUX_SOCKET and LIBTMUX_SOCKET_PATH, which a configuration that
names no server is meant to read. A run that starts a client from a
JavaExec task, such as a module-tests consumer, had no way to the
quarantine. A rule for every JavaExec task would take TMUX from `run`
and `runDevelopment`, which a developer runs from inside tmux on
purpose, so a task asks for it.

Measured with a recording tmux first on PATH and the environment of a
pane: a Test task starts its clients with no selector set and a
TMUX_TMPDIR of its own, and a JavaExec task that opted in does the same.
`runDevelopment` still follows TMUX. The first version did the work in a
closure over this script, which added a third configuration-cache
problem to `runDevelopment --configuration-cache` and to
`:libtmux:test --configuration-cache`. As an Action of plain values both
report the two that origin/HEAD does.

what:
- Give every Test task no TMUX, TMUX_PANE or LIBTMUX_SOCKET*, and a
  TMUX_TMPDIR of its own under the socket root, created 0700
- Export quarantineTmux, which takes the provider of a JavaExec task
  that runs a client in CI, and withoutServerSelectors, for a process
  that is not a task
- Replace the environment instead of removing from it: removing from
  the map a providers.javaexec spec returns left the variables in the
  child
- Add TmuxQuarantineTest, which runs a compiled program under a build
  that starts from a pane's environment: a task that opts in is
  scrubbed, one that does not is left alone, the entry survives the
  configuration cache and its reuse, and the directory is 0700. It
  reports presence and never a value. Each case failed against a
  mutant: no scrub, a closure over the script, every JavaExec
  quarantined, and a directory open to others
- Read the export in the test build with `extra[...]` and not
  `val x by extra`, which Gradle 9.6 deprecates
why: Nothing looked at the end of a run. A scratch test that ran a bare
`tmux -f /dev/null new-session -d -s rrleak` left a live server on
<root>/<id>/tmux-<uid>/default and the build was green. The only guard
was the next start refusing a leftover socket, which never stops the
server and is reached only if the same daemon runs the same task again.
A server started on `-S <quarantine>/custom.sock` was worse: a sweep of
tmux-<uid> alone does not see it, and removing the directory orphans it.

Measured with a scratch test in libtmux. Before: BUILD SUCCESSFUL with
the server alive until killed by its pid. After: the check failed with
":libtmux:test left a tmux server running: tmux-<uid>/default" and the
server was gone.
With the server on custom.sock and a test that passes, the sweep of
tmux-<uid> gave BUILD SUCCESSFUL and `ss -xlp` still listed the server.
This one gives BUILD FAILED naming custom.sock and `ss -xlp` lists
nothing. With a kill-server that does nothing it fails and keeps the
quarantine. `:libtmux:test --configuration-cache` reports the two
problems origin/HEAD does, none of them the new tasks.

what:
- Register `<task>QuarantineCheck`, which finalizes every Test task and
  each JavaExec task that opts in, so it runs when the task fails too
- Walk the whole quarantine without following links, and take as a
  socket any entry that is not a file, a directory or a link
- Ask each socket with a connect; one nothing answers on is not a leak
- Stop each server that answers with `tmux -S <socket> kill-server`,
  using the lane's binary or -PlibtmuxTmux, and verify it: a connect
  must fail within about three seconds
- Remove the directory only when nothing still answers; otherwise keep
  it, say so, and fail. Fail naming the sockets below the quarantine,
  and no value, even when the tests passed
- Register from afterEvaluate, and from quarantineTmux, which now takes
  the task's provider: Gradle refuses to register a task while another
  is being created
- Export stopLeakedServers, which returns the sockets it stopped and
  the ones still answering, for a process that is not a task
- Add TestKit cases: the default socket, a custom socket, a nested one,
  a stale socket file, a link to a server outside, a kill-server that
  does nothing, and a failing task. Each guard failed a case when
  removed: the finalizer, running after a failure, the whole-tree and
  nested scan, not following links, asking before stopping, verifying
  the stop, keeping the directory, and failing after a pass
why: libtmux-kotlin applies no libtmux.java-library, so none of its
test tasks, including the test-tmux-* lanes, ran quarantined. Its tests
reach tmux through TmuxExtension's explicit -S today, but a test that
built a bare client would have followed the caller's TMUX.

Measured with a recording tmux first on PATH and the environment of a
pane: a bare client in a libtmux-kotlin test was started with TMUX,
TMUX_PANE, both LIBTMUX_SOCKET variables and the pane's TMUX_TMPDIR
intact on origin/HEAD. With the plugin it holds none of them and a
TMUX_TMPDIR of the task's own.

what:
- Apply libtmux.tmux-quarantine to libtmux-kotlin, whose Test tasks it
  reaches by type, the matrix lanes included
why: The builds under module-tests/ are builds of their own, so no
convention of this one reaches them. Their run tasks started a consumer
program or the staged workspace CLI with the Gradle daemon's
environment, and `cli` ran `debug-info`, `load` and `freeze` through
providers.javaexec, which is a spec and not a task. The commands name
-S today; a future one that does not would follow the caller's TMUX.

Measured with a recording tmux first on PATH and the environment of a
pane, against the staged release: each `run` started its client with no
TMUX, TMUX_PANE or LIBTMUX_SOCKET*, and the `cli` child had a
TMUX_TMPDIR under its own temporary directory. With the opt-in removed
from the java run, both its clients held every selector. `debug-info`
starts no tmux at all, so it is covered by the same scrub and not by a
finding.

what:
- Apply libtmux.tmux-quarantine to java, kotlin, scala and cli by path,
  so nothing is resolved from this build
- Opt the run task of java, kotlin and scala in with quarantineTmux,
  read with `extra[...]` and not `val x by extra`, which Gradle 9.6
  deprecates and `--warning-mode all` no longer reports here
- Give cli's providers.javaexec children the same scrub and a
  TMUX_TMPDIR inside the run's own directory, and stop and report any
  server the workspace CLI left anywhere in it when the run ends; keep
  the run's directory while a server still answers
why: The quarantine is configured on the Test task, so it cannot see a
run that never went through one: a test class started from an IDE, or
a Test task registered by a module that forgot the plugin, still
inherits TMUX and the default socket. The suite then fails quietly or
drives the developer's own server. The refusal belongs in the process
that would run the tests.

Measured with the plugin's scrub disabled and the environment of a
pane: `:libtmux:test` stopped before its first test with a message
naming TMUX, TMUX_PANE, LIBTMUX_SOCKET and LIBTMUX_SOCKET_PATH. With
the marker property removed it stopped naming that instead. With the
plugin restored both modules ran.

what:
- Add the build-logic project test-sandbox: a JUnit Platform
  LauncherSessionListener, registered with ServiceLoader so it covers
  every engine, that throws before the first test unless the
  environment holds no TMUX, TMUX_PANE or LIBTMUX_SOCKET* and
  TMUX_TMPDIR is the directory the build prepared for the task; a
  finding names the variable and never prints its value
- Have libtmux.tmux-quarantine pass that directory to the test JVM as
  libtmux.test.sandbox
- Put it on the test runtime classpath of libtmux.java-library and
  libtmux-kotlin
- Run the project's own tests under the quarantine, applied by path,
  and in the root check
why: The quarantine and the refusal are opt-in per module, so a module
that registers a Test task and applies neither runs its tests with the
caller's TMUX and default socket, and nothing says so. libtmux-kotlin
was exactly that: the only module whose tests the convention did not
reach, found by reading, not by a failing build.

Shown to bite: dropping the plugin id from libtmux-kotlin, and then only
its test-sandbox dependency, each failed the task with
"these run tests outside the tmux quarantine: :libtmux-kotlin".

what:
- Add everyTestModuleIsQuarantined: every subproject with a Test task
  must apply libtmux.tmux-quarantine and have test-sandbox on its test
  runtime classpath
- Run it in the root check
why: The quarantine says what a task's environment should hold, and the
refusal checks the test JVM's own. Neither shows what the library hands
a child process, which is where a bare client picks its server: the
default endpoint adds no flag, so TMUX and TMUX_TMPDIR alone decide it.
A canary on the real path catches a change that makes the library, or
a later default-selection rule, reach past them. A recording tmux
shows what a client received; only a real one shows where its server
ends up.

Shown to bite: with the plugin's scrub and the refusal both disabled
and the environment of a pane, the live case failed naming TMUX,
TMUX_PANE, LIBTMUX_SOCKET and LIBTMUX_SOCKET_PATH. With TMUX_TMPDIR no
longer exported and the refusal removed, the real bare client started
its server outside the quarantine and its case failed. The other cases
feed the same reader a client given a pane's environment, a -S outside
the quarantine and an ambient TMUX_TMPDIR, so it is shown to flag each,
and the same client inside the quarantine to pass.

what:
- Add SandboxCanaryTest to libtmux: a bare Server.open runs a recording
  tmux, and no recorded client may hold a selector, name a socket
  outside the quarantine, or resolve its default socket elsewhere
- Start a real bare client, when tmux runs, with no -S and no -L,
  assert its server listens on the quarantine's tmux-<uid>/default, and
  stop it with -S; a control started with another TMUX_TMPDIR shows the
  assertion can fail
- Have the recording tmux write argv, the names of the selectors it
  saw and whether TMUX_TMPDIR is the quarantine, and print no value in
  any message: an environment holds secrets; a failure reports only
  whether -S or -L was present, not what they named
why: api/run.sh cleared TMUX and TMUX_PANE and nothing else. With
decoys planted, the program it starts still saw LIBTMUX_SOCKET,
LIBTMUX_SOCKET_PATH and the caller's TMUX_TMPDIR, so a client in it that
named no socket could land on a server the runner did not own. A server
the program left behind outlived the run, and one on a socket of its own
choosing was not found even by a cleanup that looked. And a closed
output ended the runner by SIGPIPE before its cleanup ran, which
`sh run.sh prog | head` reproduces.

Measured with the new test against the previous runner: all five cases
that existed then failed. Each part of the new runner was removed in
turn, and each removal failed a case.

what:
- Unset TMUX, TMUX_PANE and every LIBTMUX_SOCKET* before the program
  starts
- Give it a TMUX_TMPDIR from a fresh 0700 mktemp directory under a short
  root, and refuse a root whose default socket path would pass 100
  bytes; API_RUN_ROOT moves the root, so the test can
- On exit, hangup, interrupt, termination and a closed output, find
  sockets anywhere under both directories without following links, stop
  each server that answers with -S and verify the stop for about three
  seconds, and remove the directories only when nothing answers. A
  server the program left fails the run naming its socket, even when
  the program succeeded; one that will not stop keeps both directories
  and says so. A socket nothing answers on is not a leak
- Add ApiRunnerSandboxTest to the examples suite: the selectors, the
  private mode-0700 TMUX_TMPDIR with and without one in the caller's
  environment, the length refusal, a server on the default socket, a
  custom one and a nested one, a stale socket file, a link to a server
  outside, a kill-server that does nothing, and a terminated run; the
  probe reports names and booleans only
- Say so in the examples README
why: CONTRIBUTING.md told a contributor which roots a test may use and
never the default socket, and not how the build keeps a task off it. A
refused run from an IDE, a server the check stops, or a module the gate
names is otherwise a message with no page behind it. Nor did it say
which runs are left alone: `runDevelopment` follows the caller's TMUX on
purpose.

what:
- Say what each Test task and module-tests run starts without and with
- Say which development runs are not quarantined, and to pass -S
- Say what the finalizer does when a quarantined task leaves a server:
  it looks anywhere in the quarantine without following links, fails
  even after a pass, and keeps the directory while a server answers; and
  that the examples runner does the same
- Say the test JVM refuses to start otherwise, and to run through
  ./gradlew
- Name the gate for a module with tests
@tony tony changed the title Keep test runs away from the developer's tmux server Sandbox: Keep test runs away from the developer's tmux server Nov 1, 2026
tony added 19 commits November 1, 2026 07:12
why: The finalizer looked for servers only under a task's TMUX_TMPDIR,
but a test names its own socket under java.io.tmpdir, which was the
root every task and checkout shares. A probe test that left a server on
a @tempdir socket passed the build, and JUnit then deleted the socket,
so the server lived on with no path to it. The scan covered the
quarantine, not the tree the tests actually use.

what:
- Give java.io.tmpdir the same directory as TMUX_TMPDIR, for every
  task the quarantine covers, so the sockets a program names itself
  are inside the swept tree
- Set junit.jupiter.tempdir.cleanup.mode.default to never on Test
  tasks, so a @tempdir socket is still on disk when the finalizer
  looks; the finalizer removes the quarantine itself afterwards
- Pin both in the canary, and in TmuxQuarantineTest a leaked server on
  a socket under the JVM's temporary directory, found and stopped
- Say in CONTRIBUTING which servers the finalizer cannot see
why: Two of the six contract points had no test that fails without
their code. The cap on the socket root, which keeps every socket path
inside the 100 bytes AF_UNIX allows, could be raised with the whole
suite still green. So could the listener's body or its ServiceLoader
entry, because the tests that ran under the listener were sandboxed and
never saw it refuse.

what:
- Refuse a libtmuxSocketRoot of 41 characters in TmuxQuarantineTest
  before the task runs and before any directory is made
- Run the listener in a child JVM handed a pane's environment, one with
  no quarantine and one the build prepared, and find it through
  ServiceLoader as the JUnit Platform does
- Put the launcher API on the test-sandbox compile classpath
why: A contributor who runs the tests now gets a refusal outside
./gradlew and a failed build for a server a test leaves, which a
changelog reader would otherwise meet with no explanation.

what:
- Add a Development entry under Unreleased for the guard
why: The quarantine is now the JVM's temporary directory, and a Test
task keeps its @tempdir until the finalizer runs. A run cancelled in a
Gradle daemon that survives leaves those files behind. The next run of
the task in that daemon found them, took them for stale sockets and
refused to start, though only a socket can have a server behind it.

what:
- Delete plain files in an existing quarantine and refuse only what is
  neither a directory nor a regular file
- Pin both: temporary files left by an earlier build in the same daemon
  are cleared, and a socket is still refused by name; the cases skip
  when the builds land in different daemons
why: everyTestModuleIsQuarantined sat in the root build script, where
no test could reach it, so nothing showed it could fail. A module that
lost the plugin or the listener would have been caught only if the gate
worked as written, and that was never exercised.

what:
- Move the task into libtmux.tmux-quarantine-gate, which the root build
  applies by path like the module-tests builds apply the quarantine
- Add QuarantineGateTest: a covered module passes, and a module without
  the plugin or without the listener fails the build and is named
why: The contract has messages carry names and booleans, never values.
The plugin printed the quarantine's path, which comes from TMUX_TMPDIR,
and the root a caller gave libtmuxSocketRoot. run.sh printed both of its
directories, which sit under API_RUN_ROOT, a value the caller sets.

what:
- Name a stale entry by its path below the quarantine, and a
  quarantine that is not a directory by its own name
- Leave the root out of the message that refuses it
- Have run.sh name the two directories it kept, not their paths
why: The probes in TmuxQuarantineTest run JavaExec tasks only. The line
that finalizes every Test task, which is the case the issue is about,
and the lookup of the tmux binary a matrix lane names could be deleted
with the whole suite still green.

what:
- Run a build whose Test task leaves a server on a socket in the JVM's
  temporary directory, with libtmux.tmux naming a tmux that ignores
  kill-server, and require the failure to say the quarantine was kept
why: The real-client canary and its control were skipped when tmux could
not be started, so a run on a host without it passed with the guard
unobserved. Every other test in libtmux fails hard without tmux.

what:
- Fail the canary when the tmux it was given does not run
why: Only the listener and the tests, all in its package, use
SandboxCheck, and CLAUDE.md keeps new API package-private until a caller
outside the package needs it.

what:
- Make the class and its three members package-private
why: WRITING.md keeps a source comment to one or two lines and sends
rationale to the commit message. The header ran twelve lines and the
root comment eight. What they argued, kept here:

tmux resolves its default socket under TMUX_TMPDIR when it execs, and an
inherited TMUX takes precedence for a client started inside a pane,
which the Gradle daemon may be. A test is supposed to pass -S, but
nothing in the language enforces it, and a command that omits it
addresses a real server and can kill it.

Sibling libtmux ports start their own tmux servers under /tmp on the
same machine. A server of theirs left by an exited run holds a pty and
answers to a name this suite might have chosen, so their debris became
this suite's intermittent failures. This port takes a root of its own
and everything a task creates lands under it. Every task and checkout
shares that root, so no finalizer sweeps it whole.

JUnit removes a @tempdir when its test ends and the socket of a server
the test left goes with it; -S cannot reach a server whose socket is
gone, so the finalizer would find nothing.

what:
- Cut the header, the root, the sweep, the two class and the
  tempdir comments to the fact
why: The testing guide still said a task refuses to remove a stale file
or socket, and named neither LIBTMUX_SOCKET* nor the end-of-task sweep.
The contributing guide and the changelog claimed more than the guard
does: operationBenchmark is a Test task and is quarantined, the
build-logic tests are outside the gate, and the module-tests/cli
children get the scrub and a TMUX_TMPDIR but not a temporary directory.

what:
- Rewrite the guide's isolation section for the quarantine as it is
- Say which Test tasks, which benchmark and which module-tests child
  the guard covers, and that the build-logic tests are outside the gate
- Drop the changelog's claim of a temporary directory for every run
why: The stale-socket case skipped when the build neither refused nor
printed the quarantine's name, to tell another daemon from a refusal.
A message that named the wrong path looked like another daemon, so
breaking the message left the build green.

what:
- Treat any stale-entry message as proof the build ran in this daemon,
  then require it to name the entry below the quarantine
why: The module-tests builds resolve nothing from the root build, so the
root gate cannot see them. Deleting the line that opts a run into the
quarantine, or the scrub in the cli build, left every test green while
the changelog and the contributing guide said each run was covered.

what:
- Read the four module-tests build scripts and require that each
  applies the quarantine file, that java, kotlin and scala opt their run
  in, and that cli scrubs its children, gives them a TMUX_TMPDIR and
  stops what they leave
why: Ten finalizer cases skipped when tmux was missing, so a job
without it passed with the proof of contract point 5 skipped; the
canary already fails there. Freshness was unpinned too: deleting the
step that clears an existing quarantine left every case green, because
the finalizer removes the directory afterwards.

what:
- Fail the cases that need tmux instead of skipping them; only the
  two-daemon cases may still skip
- Have the probe report how many entries the quarantine held when the
  task started, and require none after debris was planted
- Wrap the lines past 120 columns, and cut the class comment to the
  fact it carries
why: The canary checked that the junit property was set, not that JUnit
honours it, so a rename of the property or a JUnit that ignored it left
the check green while @tempdir sockets vanished before the finalizer.

what:
- Remember each test's @tempdir and require, after the last test, that
  every one still exists
- Replace the property assertion, wrap the Javadoc line past 120
  columns and cut the class comment to the fact it carries
why: Four assertions appended the runner's whole output to their
failure message. A tmux error in that output can carry a path under
TMUX_TMPDIR, which the contract keeps out of messages.

what:
- Say the launcher failed and leave its output to the assertions that
  look for fixed strings in it
- Cut the class comment to the fact it carries
why: .editorconfig sets 120 columns for kts, and WRITING.md keeps a
source comment to one or two lines. The test-sandbox build header argued
for six; it held the fact that the listener is the in-process half and
that a project in this build cannot apply the product modules' compiler
settings to itself. The listener test's class comment explained why a
child JVM is needed: this JVM's environment cannot be changed.

what:
- Wrap the run.sh message and the root check dependency
- Cut the test-sandbox header and the listener test comment to the fact
why: run.sh reads API_RUN_ROOT and refuses a root whose default socket
path would not fit, but its README documented only TMUX_BIN. The
contributing guide named two fixtures whose servers the finalizer
cannot see; a few tests also make directories directly under the shared
root.

what:
- Describe API_RUN_ROOT and its default beside TMUX_BIN
- Say the same of the tests that name a path under the root, and that
  nothing checks they end their servers
why: The negative control asserted that no socket file existed in the
quarantine. tmux can leave the socket file of a server it stopped, so
the control failed whenever the bare-client case ran first, and passed
only because JUnit's default method order put it earlier.

what:
- Ask the quarantine's socket whether a server answers, which a stale
  file does not
- Cut the Javadoc of the recording tmux to the fact it carries
tony added 6 commits November 1, 2026 09:30
why: CLAUDE.md puts every tmux server this project starts under
/tmp/libtmux-java-test. The plugin tests rooted their quarantines in
the JVM's temporary directory, outside it, and put the link control's
server in a JUnit temp directory, where a killed run left servers that
tools/reap-stale-servers.sh could not find.

what:
- Root the probe quarantines and the link control's socket under
  /tmp/libtmux-java-test/tq<pid>
- Wrap the lines past 120 columns
why: The runner refuses a root whose default socket path passes 100
bytes. The tests rooted it under a @tempdir inside the quarantine, which
left one byte of slack at uid 1000 and none from a five-digit uid, where
every case failed with "API_RUN_ROOT is too long".

what:
- Give each test a short root of its own under the quarantine
why: The cleanup check ran in a finally block, so a leaked server
replaced the exception that ended the run and hid why it ended.

what:
- Keep the failure of the run as the one thrown, and add the leaked
  servers to it as suppressed; throw them alone when the run passed
why: "Nothing checks that they did" was wider than the claim: the
fixtures do prove their own teardown. What is missing is the
finalizer's view of servers outside its tree.

what:
- Say the finalizer does not check that those tests end their servers
why: The cli run starts its server on an explicit socket in its own
directory and stops it with kill-server, ignoring the result. The sweep
that followed looked only at the TMUX_TMPDIR beside it, then deleted
the directory, so a server that ignored kill-server lost its socket
unseen, which the contract forbids.

what:
- Sweep the run's whole directory, which holds the explicit socket and
  the TMUX_TMPDIR, before deleting it
- Wrap the line past 120 columns
why: The test reads lines of text, which a comment could satisfy, and
the builds it covers cannot run without a staged repository. The class
comment should say so rather than leave a reader to find out.

what:
- Add that to the class comment
@tony
tony force-pushed the test/sandbox-default-server branch from 2a43703 to b1a7266 Compare November 1, 2026 15:42
@tony
tony marked this pull request as ready for review November 1, 2026 15:45
@tony
tony merged commit ef44575 into master Nov 1, 2026
18 checks passed
tony added a commit that referenced this pull request Nov 1, 2026
why: Cut 0.1.2 from master so the test-run sandbox guard (#123) and its
cli await fix (#130), the per-module changelogs (#125) and the Kotlin and
Scala Server capability directory (#127) ship. The install snippets and
the examples' snapshot coordinates need to name the release that ships
them.

what:
- Date the 0.1.2 section (2026-11-01) in the root changelog and in each
  module's, and keep an empty unreleased section above it; the modules
  with nothing new say so
- Move the install snippets and the examples' snapshot coordinates to
  0.1.2
- Set the development version to 0.1.2-SNAPSHOT; libtmuxApiBaseline
  stays until 0.1.2 is published
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Keep test runs away from the developer's tmux server

1 participant