From 64a4f762ac867f242c0d605820dad578331f2f02 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 12 Nov 2026 17:54:47 -0600 Subject: [PATCH 1/4] Pane(fix[run]): Wait for a new pane's shell why: The release rehearsal on master failed with "running a command to its end needs a POSIX shell in the pane, and it is running 'tmux'". The staged consumer creates a session and calls Pane.run on its pane at once. Between fork and exec the process in a pane's terminal is the server's own copy, so #{pane_current_command} reads "tmux" for a moment, and Pane.run read it once and refused. The same call passed on the previous master run, so it is a race, not a change: 3 of 150 reads straight after new-session -d returned "tmux" on tmux 3.8a. Alternatives considered: - Wait in the consumer. Every caller that follows the README, which runs a command in a pane it just made, has the same race. - Accept "tmux" as a shell. A pane that really runs tmux would then be typed into as if it read POSIX syntax. - Poll for the prompt. The library does not know the shell's prompt. The wait is bounded, so a pane that stays "tmux" is refused as before, after the wait. what: - Make Pane.run await a name other than "tmux" for up to two seconds when its first read says "tmux", then read the command and the socket again before the shell check - Test it with a pane whose process is a symlink named tmux that sleeps before it execs a shell: refused with the CI message before the change, runs the command after it - Record the measurement in docs/internals/tmux-behaviour.md --- docs/internals/tmux-behaviour.md | 9 ++++++ .../libtmux/it/PaneRunIntegrationTest.java | 28 +++++++++++++++++++ .../src/main/java/io/github/libtmux/Pane.java | 11 ++++++-- .../java/io/github/libtmux/PaneCommand.java | 7 +++++ 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/docs/internals/tmux-behaviour.md b/docs/internals/tmux-behaviour.md index a03d2ebac..622754966 100644 --- a/docs/internals/tmux-behaviour.md +++ b/docs/internals/tmux-behaviour.md @@ -93,6 +93,15 @@ through a client's key table and a clock takes no such commands. `display-panes` and `display-popup` require an attached client on every lane and are not exposed for that reason, the same as `new-session -A` above. +## A pane is named `tmux` until its shell starts + +Between fork and exec the process in a pane's terminal is the server's own +copy, so `#{pane_current_command}` reads `tmux`. Reading it straight after +`new-session -d` returned `tmux` in 3 of 150 attempts on tmux 3.8a, one tmux +invocation per read on an idle host, and a loaded CI runner widens the window. +A caller that must know the pane's program, as `Pane.run` must to check for a +POSIX shell, waits for another name rather than trusting the first read. + ## run-shell output is missing on 3.3a and 3.4 only Reading what `run-shell` printed silently reports nothing on 3.3a and 3.4, and diff --git a/integration-tests/src/test/java/io/github/libtmux/it/PaneRunIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/PaneRunIntegrationTest.java index 6a71463da..75f68adbf 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/PaneRunIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/PaneRunIntegrationTest.java @@ -12,11 +12,14 @@ import io.github.libtmux.TextOutcome; import io.github.libtmux.Window; import io.github.libtmux.junit5.TmuxExtension; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Duration; import java.util.List; import java.util.OptionalInt; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; /** * Running a command in a pane to its end: the status is the shell's, and the output is only the @@ -127,6 +130,31 @@ void aCommandStillRunningAtTheDeadlineIsATimeout(Server server) throws Interrupt assertEquals(List.of("started"), ran.output(), "what it had printed by the deadline"); } + /** + * Between fork and exec the process in a pane's terminal is the server's own copy, and tmux + * names the pane {@code tmux}. A command run in a pane created a moment ago must wait for the + * shell to start, not refuse the pane as a program that is not one. + */ + @Test + void aPaneThatHasNotStartedItsShellYetIsWaitedFor(Server server, @TempDir Path directory) throws Exception { + Path named = Files.createSymbolicLink(directory.resolve("tmux"), Path.of("/bin/sh")); + Pane pane = server.newSession(session -> session.named("forking") + .running(named.toString(), "-c", "sleep 0.7; exec env PS1='$ ' ENV= /bin/sh")) + .windows() + .get(0) + .panes() + .get(0); + assertEquals( + "tmux", + pane.variables(List.of("pane_current_command")).get("pane_current_command"), + "the pane still carries the name of the process that has not exec'd yet"); + + PaneRun ran = pane.run("printf 'started\\n'", GENEROUS); + + assertEquals(List.of("started"), ran.output()); + assertEquals(OptionalInt.of(0), ran.exitStatus()); + } + @Test void aPaneNotRunningAShellIsRefused(Server server) { Pane pane = server.newSession(session -> session.named("not-a-shell").running("sleep", "60")) diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index 17696aac1..27834d369 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -448,7 +448,8 @@ public TextWait watchText(String text, Duration timeout, Duration every) throws * *

It runs in the shell a person set up — its directory, its environment, a virtualenv someone * activated — which is the reason to type it there rather than start a process of your own. The - * pane has to be running a POSIX shell for the typed line to mean what it says. + * pane has to be running a POSIX shell for the typed line to mean what it says. A pane created a + * moment ago, whose shell has not started yet, is waited for briefly. * *

A command still running at the deadline keeps running; the answer carries what it had printed * by then, and {@link PaneRun#exact()} is false. @@ -466,8 +467,14 @@ public PaneRun run(String command, Duration timeout) throws InterruptedException Objects.requireNonNull(timeout, "timeout"); // What the pane is running and where its server listens, read together: one command, and the // same moment, so the shell checked is the shell the line is typed into. - Map here = variables(List.of("pane_current_command", "socket_path")); + List read = List.of("pane_current_command", "socket_path"); + Map here = variables(read); String running = here.getOrDefault("pane_current_command", ""); + if (running.equals(PaneCommand.FORKED_SERVER)) { + await(fresh -> !fresh.currentCommand().equals(PaneCommand.FORKED_SERVER), PaneCommand.EXEC_GRACE); + here = variables(read); + running = here.getOrDefault("pane_current_command", ""); + } PaneCommand.requirePosixShell(running.isEmpty() ? state.currentCommand() : running); PaneCommand frame = PaneCommand.fresh(); List tmux = List.of(server.config().binaryPath(), "-S", here.getOrDefault("socket_path", "")); diff --git a/libtmux/src/main/java/io/github/libtmux/PaneCommand.java b/libtmux/src/main/java/io/github/libtmux/PaneCommand.java index 28ec662a2..99e0ab7d7 100644 --- a/libtmux/src/main/java/io/github/libtmux/PaneCommand.java +++ b/libtmux/src/main/java/io/github/libtmux/PaneCommand.java @@ -2,6 +2,7 @@ import io.github.libtmux.catalog.Advanced; import java.security.SecureRandom; +import java.time.Duration; import java.util.ArrayList; import java.util.HexFormat; import java.util.List; @@ -41,6 +42,12 @@ public final class PaneCommand { /** The shells whose syntax the typed line is written in. */ private static final Set POSIX_SHELLS = Set.of("sh", "ash", "bash", "dash", "ksh", "mksh", "pdksh", "zsh"); + /** What a pane reports between fork and exec, while its foreground process is still the server. */ + static final String FORKED_SERVER = "tmux"; + + /** How long a pane named {@link #FORKED_SERVER} is given to exec its shell before it counts as running tmux. */ + static final Duration EXEC_GRACE = Duration.ofSeconds(2); + private final String startMark; private final String endMark; private final String channel; From 75bb1ed651f5aa7b52919c6a46520abd6f11b661 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 12 Nov 2026 17:54:53 -0600 Subject: [PATCH 2/4] changelog(docs): Note Pane.run waits for a shell why: A caller can observe the change: Pane.run on a pane created a moment ago no longer throws while its shell starts. what: - Add a Fixed entry under Unreleased in libtmux/CHANGELOG.md --- libtmux/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/libtmux/CHANGELOG.md b/libtmux/CHANGELOG.md index 0d948192a..381672328 100644 --- a/libtmux/CHANGELOG.md +++ b/libtmux/CHANGELOG.md @@ -31,6 +31,14 @@ changelog](../CHANGELOG.md). [migration notes](../MIGRATION.md#output-subscriptions-bound-payload-bytes). (#20) +### Fixed + +- **`Pane.run` waits for the shell of a pane created a moment ago.** tmux names + such a pane `tmux` until its shell starts, and `Pane.run` refused it with + `IllegalStateException` on the first read. It now waits up to two seconds for + another name. A pane still named `tmux` after that is refused as before. + (#156) + ## 0.1.2 — 2026-11-01 No entries for `libtmux`. From 71167dfe79a3c02868a9cd550a5f34a25e85c5e5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 12 Nov 2026 19:15:36 -0600 Subject: [PATCH 3/4] Mcp(fix[run]): Wait for a new pane's shell why: run_shell_command read the pane's command once, as Pane.run did before the previous commit, so a pane created a moment before the call was refused with "run_shell_command requires a POSIX-compatible shell in the target pane; it is running 'tmux'". tmux names a pane "tmux" between fork and exec. The wait added to Pane.run belongs in one place that both callers use, and the MCP server is the outside caller PaneCommand is public for. The cohort read that follows also compares the pane's command before and at dispatch, so a pane that changed from "tmux" to a shell in between would have been refused as changed under the run. Waiting before the first read keeps both reads on the shell. what: - Move the wait out of Pane.run into PaneCommand.awaitShellStart, which waits only when the command it is given is "tmux" and says whether it did, so the caller reads the pane again - Call it from Pane.run and from RunningCommands.run, which resolves the input cohort again after a wait - Add RunningCommandsTest aPaneThatHasNotStartedItsShellYetIsWaitedFor: a pane whose process is a symlink named tmux that sleeps before it execs a shell. It failed before the change with the refusal above, and passes after it - Name the MCP caller in docs/internals/tmux-behaviour.md --- docs/internals/tmux-behaviour.md | 5 ++-- .../github/libtmux/mcp/RunningCommands.java | 8 ++++++ .../libtmux/mcp/RunningCommandsTest.java | 26 +++++++++++++++++++ .../src/main/java/io/github/libtmux/Pane.java | 3 +-- .../java/io/github/libtmux/PaneCommand.java | 25 ++++++++++++++++-- 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/docs/internals/tmux-behaviour.md b/docs/internals/tmux-behaviour.md index 622754966..bf4c70851 100644 --- a/docs/internals/tmux-behaviour.md +++ b/docs/internals/tmux-behaviour.md @@ -99,8 +99,9 @@ Between fork and exec the process in a pane's terminal is the server's own copy, so `#{pane_current_command}` reads `tmux`. Reading it straight after `new-session -d` returned `tmux` in 3 of 150 attempts on tmux 3.8a, one tmux invocation per read on an idle host, and a loaded CI runner widens the window. -A caller that must know the pane's program, as `Pane.run` must to check for a -POSIX shell, waits for another name rather than trusting the first read. +A caller that must know the pane's program, as `Pane.run` and the MCP +`run_shell_command` must to check for a POSIX shell, waits for another name +(`PaneCommand.awaitShellStart`) rather than trusting the first read. ## run-shell output is missing on 3.3a and 3.4 only diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index 6ced58c0a..f96491847 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -81,6 +81,14 @@ static Ran run(Call call) { boolean suppressHistory = call.flag("suppress_history", true); PaneInputCohort.Resolution initial = PaneInputCohort.resolve(pane, call.caller()); String currentCommand = initial.requireSingularCommandPane("run_shell_command"); + try { + if (PaneCommand.awaitShellStart(pane, currentCommand)) { + initial = PaneInputCohort.resolve(pane, call.caller()); + currentCommand = initial.requireSingularCommandPane("run_shell_command"); + } + } catch (InterruptedException cancelled) { + throw Waits.cancelled(cancelled); + } requirePosixShell(currentCommand); PaneInputReservations.Lease lease = PaneInputReservations.run(pane, initial, "run_shell_command"); boolean retained = false; diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 4883ec6f9..cfd740d5c 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -512,6 +512,32 @@ void aPaneNotRunningAPosixShellIsRefused(Server server) { "the rejected payload must not reach the foreground program"); } + /** + * Between fork and exec the process in a new pane is the server's own copy, and tmux names the + * pane {@code tmux}. A run in a pane created a moment ago waits for the shell to start rather + * than refusing the pane as another program. + */ + @Test + void aPaneThatHasNotStartedItsShellYetIsWaitedFor(Server server, @TempDir Path temporary) throws Exception { + Path named = Files.createSymbolicLink(temporary.resolve("tmux"), Path.of("/bin/sh")); + Pane pane = server.newSession(session -> session.named("forking") + .running(named.toString(), "-c", "sleep 0.7; exec env PS1='$ ' ENV= /bin/sh")) + .windows() + .get(0) + .panes() + .get(0); + assertEquals( + "tmux", + pane.variables(List.of("pane_current_command")).get("pane_current_command"), + "the pane still carries the name of the process that has not exec'd yet"); + + RunningCommands.Ran ran = RunningCommands.run( + TestCalls.on(server, "pane_id", pane.id().value(), "command", "printf 'started\\n'", "timeout", 20)); + + assertCompleted(ran, 0); + assertEquals(List.of("started"), ran.output()); + } + /** * The reason this tool exists rather than send-then-look: a failure is a number, not something to * infer from what the screen says. diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index 27834d369..5356fc9ca 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -470,8 +470,7 @@ public PaneRun run(String command, Duration timeout) throws InterruptedException List read = List.of("pane_current_command", "socket_path"); Map here = variables(read); String running = here.getOrDefault("pane_current_command", ""); - if (running.equals(PaneCommand.FORKED_SERVER)) { - await(fresh -> !fresh.currentCommand().equals(PaneCommand.FORKED_SERVER), PaneCommand.EXEC_GRACE); + if (PaneCommand.awaitShellStart(this, running)) { here = variables(read); running = here.getOrDefault("pane_current_command", ""); } diff --git a/libtmux/src/main/java/io/github/libtmux/PaneCommand.java b/libtmux/src/main/java/io/github/libtmux/PaneCommand.java index 99e0ab7d7..8ae025667 100644 --- a/libtmux/src/main/java/io/github/libtmux/PaneCommand.java +++ b/libtmux/src/main/java/io/github/libtmux/PaneCommand.java @@ -43,10 +43,10 @@ public final class PaneCommand { private static final Set POSIX_SHELLS = Set.of("sh", "ash", "bash", "dash", "ksh", "mksh", "pdksh", "zsh"); /** What a pane reports between fork and exec, while its foreground process is still the server. */ - static final String FORKED_SERVER = "tmux"; + private static final String FORKED_SERVER = "tmux"; /** How long a pane named {@link #FORKED_SERVER} is given to exec its shell before it counts as running tmux. */ - static final Duration EXEC_GRACE = Duration.ofSeconds(2); + private static final Duration EXEC_GRACE = Duration.ofSeconds(2); private final String startMark; private final String endMark; @@ -210,6 +210,27 @@ public record Framed(@ReadOnly List lines, boolean exact, OptionalInt st } } + /** + * Waits for a pane that was just created to start its shell, and says whether it had to. + * + *

Between fork and exec the process in a pane's terminal is the server's own copy, so tmux + * names the pane {@code tmux} until its shell starts. A read of the pane's command taken in that + * window is out of date once this returns {@code true}, and has to be taken again. A pane still + * named {@code tmux} after the wait is left for the caller's shell check to refuse. + * + * @param pane the pane to wait for + * @param current the command the pane was last read to be running + * @return whether the pane was waited for, so that {@code current} is stale + * @throws InterruptedException if the waiting thread is interrupted + */ + public static boolean awaitShellStart(Pane pane, String current) throws InterruptedException { + if (!current.equals(FORKED_SERVER)) { + return false; + } + pane.await(fresh -> !fresh.currentCommand().equals(FORKED_SERVER), EXEC_GRACE); + return true; + } + /** Refuses a pane whose shell would not read the typed line as written. */ static void requirePosixShell(String current) { int slash = current.lastIndexOf('/'); From 9ca8393d11adaa3e3f10332e9c44ceb59f0c2dbe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 12 Nov 2026 19:15:49 -0600 Subject: [PATCH 4/4] changelog(docs): Note run_shell_command waits for a shell why: A caller can observe two changes: run_shell_command on a pane created a moment ago no longer refuses it while its shell starts, and PaneCommand has a new public method both run paths use. what: - Add a Fixed entry under Unreleased in libtmux-mcp/CHANGELOG.md - Add an Added entry for PaneCommand.awaitShellStart under Unreleased in libtmux/CHANGELOG.md --- libtmux-mcp/CHANGELOG.md | 7 +++++++ libtmux/CHANGELOG.md | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/libtmux-mcp/CHANGELOG.md b/libtmux-mcp/CHANGELOG.md index 4c95ad78f..cbd9751bb 100644 --- a/libtmux-mcp/CHANGELOG.md +++ b/libtmux-mcp/CHANGELOG.md @@ -14,6 +14,13 @@ changelog](../CHANGELOG.md). ## Unreleased +### Fixed + +- **`run_shell_command` waits for the shell of a pane created a moment ago.** + tmux names such a pane `tmux` until its shell starts, and the tool refused it + as not running a POSIX shell. It now waits up to two seconds for another + name. A pane still named `tmux` after that is refused as before. (#156) + ## 0.1.2 — 2026-11-01 No entries for `libtmux-mcp`. diff --git a/libtmux/CHANGELOG.md b/libtmux/CHANGELOG.md index 381672328..9c9f67ad2 100644 --- a/libtmux/CHANGELOG.md +++ b/libtmux/CHANGELOG.md @@ -22,6 +22,10 @@ changelog](../CHANGELOG.md). - **Control output preserves pane bytes and supports incremental text decoding.** Subscribers can distinguish output gaps, connection closure and transport failures while retaining the original termination cause. (#20) +- **`PaneCommand.awaitShellStart(pane, current)` waits for the shell of a pane + created a moment ago.** It waits only when `current` is `tmux`, up to two + seconds, and returns whether it waited, so the caller reads the pane's command + again. `Pane.run` and the MCP server's `run_shell_command` use it. (#156) ### Changed