Hypothesis
Why does
tmux send-keys
behave differently in a bash script?
There is possibly a race condition. The script runs tmux send-keys …
just after tmux new-session -d
. When you type the commands in an interactive shell (or paste one by one), there is a delay.
While working with tmux it's good to remember this is a client-server architecture: there is a tmux server and there are tmux clients. Basically every tmux
command you use is a client. If there is no server yet then in some circumstances the client may start a server; but even then the server is a separate process and the tmux
process you have created is still a client.
Your tmux new-session -d
starts a tmux server if needed. It tells the (newly created or old) server to create a new session with one window with one pane and to run something inside the pane. You did not supply an explicit command, so the server runs your login shell there (in general this may be customized). So far so good.
After telling the server what to do, tmux new-session -d
exits without waiting for the process started in the new pane to terminate. It makes perfect sense: the process in your case is an interactive shell that will wait for input and won't terminate by itself; you certainly do not want the tmux client to wait. The script proceeds to tmux send-keys …
immediately and this new client tells the server to send keys to the newly created pane.
The tmux server does not wait for the process either, in particular it does not even wait for the process to print a prompt or anything. All the server has to do is to set up a pseudoterminal and start the new process with stdin, stdout and stderr attached to the pseudoterminal; then it's ready to send keys (or do whatever) at the request of the next tmux client.
This means the keys may (and in your case do) get to the pseudoterminal before the newly created shell is able to handle them, even before it configures the pseudoterminal to its needs. When you see pwd
and ls
"printed to stdout" for the first time, it's because the pseudoterminal is still in cooked mode and echoes the input.
Now this does not mean the input is discarded. The pseudoterminal does buffer input until something reads from it. I think the buffer is about 4 KiB; even if I'm wrong with the number, it's certainly big enough to buffer pwd
and ls
.
And then in your case something reads from the pseudoterminal and echoes pwd
and ls
for the second time. In my tests with bash the input did get to the shell and the commands were executed. Maybe your shell is different, maybe it reads and discards all input before printing the first prompt (you told us the shell that runs tmux clients is bash, but you did not tell us what the shell in the newly created tmux pane is and this is the shell I'm talking about). Or maybe your shell runs something that reads all input. E.g. in my test with bash I put the following into my ~/.bashrc
:
IFS= read -r -N 9999999 -t 0.1; printf %s "$REPLY"
and it replicates your problem.
In your case I do not know what reads and echoes all input before your shell inside tmux gets functional.
Poor solution
A solution that may work is to wait a moment after tmux new-session -d
:
tmux new-session -dsleep 1tmux send-keys pwd C-mtmux send-keys ls C-m
This is a poor solution because there is no guarantee that one second is enough (in general there is no guarantee that any given interval is enough). It's also poor because sending keys is a very cumbersome way to run something in tmux.
Better solution
A better way to run something in tmux is to tell the tmux server exactly what to run, instead of telling it to run an interactive shell and then to emulate "typing" in hope the shell will pick it up. Tmux commands new-session
, new-window
, split-window
, respawn-window
and respawn-pane
can take shell code. Example:
tmux new-session -d 'pwd; ls; tail -f /dev/null'
I used tail -f /dev/null
so the shell interpreting the shell code does not exit after ls
(this would probably make the tmux server destroy the pane, the window, the session and possibly exit; see "Terminating the tmux session" in this answer). To terminate tail
type Ctrl+c in the pane. Another way to keep the pane open is to exec
to an interactive shell:
tmux new-session -d 'pwd; ls; exec bash'
Note pwd
and ls
are not run by the interactive shell. They are run in a non-interactive shell tmux uses to parse the shell code (including exec bash
that gives you the interactive shell). This has consequences:
- There is no prompt that "separates" commands.
- Commands are not echoed (like after a prompt).
- Commands won't be saved in history.
- The non-interactive shell does not source startup scripts, so e.g. variables set there won't matter.
- The non-interactive shell will not automatically expand aliases.
- Shell variables will not be inherited by the interactive shell, unless exported.
If you want an interactive shell to run something before you interact with it, it may still be better to tell it via the right option instead of "typing" via tmux send-keys
. For bash the right option is --rcfile
:
tmux new-session -d 'exec bash --rcfile ~/my_custom_file'
where ~/my_custom_file
is a custom file that will be sourced by bash in place of ~/.bashrc
*. In the file call pwd
, ls
or whatever. You probably want to source ~/.bashrc
anyway, so do it explicitly in the custom file. Do not spawn another bash
.
* Some distros ship bash
that sources /etc/bash.bashrc
before ~/.bashrc
, then with --rcfile
you replace them both with a single custom file.
Avoid tmux send-keys
, unless sending keys is actually what you want to do or there is no other simple way to achieve what you want to achieve.
Idea
Tmux lets you script many things you otherwise do by hand. When typing manually, you wait for a prompt, right? If you really want to use tmux send-keys
to "type" commands into an interactive shell in tmux then consider implementing such waiting as well. Parsing the output of tmux capture-pane -p
in a loop until the last non-empty line is a prompt is a way. Not a great way though, I'm mentioning it only to say it is possible.
until line="$(tmux capture-pane -p | sed '/^$/d' | tail -n 1)" case "$line" in kamil@*:*$ ) true ;; * ) false ;; esacdo sleep 1done
The code contains my username because it detects my prompt (kamil@*:*$
); adjust it to your needs.
Targeting things in tmux
And what is the best way to start a detached tmux session (or window or pane or whatever), such that I know its name?
In tmux you can give names to sessions and to windows but not to panes. What you did with tmux new-session -t tmuxA -d
is not naming the session though. See this other answer of mine and understand the difference between new session -t
and new-session -s
.
The name of a tmux session identifies the session; the name of a tmux window identifies the window. You can use such names with tmux send-keys -t
or so. But a session may contain many windows and a window may contain many panes, so in general targeting a session might not be reliable when doing things to a window or a pane (as opposed to doing things to the session itself) and targeting a window might not be reliable when doing things to a pane (as opposed to doing things to the window itself).
See how tmux interprets the option-argument given after -t
. Whenever there is ambiguity, there are rules to pick the "right" thing (e.g. if the command expects a pane as a target and you specify a window, the currently active pane in the specified window is used); and there are rules that pick the "right" thing when you simply omit -t
. The rules make life easier when you use tmux interactively, or you can take advantage of them when writing a script that uses a tmux server/session/window exclusively, but if two entities (like you and your script(s)) use the same tmux server then it's better they (especially the script(s)) target things as specifically as possible and try to avoid actions that may surprise less rigorous entities (e.g. in a script you shall use split-window
with -d
, so the new pane does not become active; unless you specifically want it to become active).
Many tmux commands need to target a pane, the most reliable way is to specify just the right pane. There are several ways to specify a pane, the most reliable is to give the pane ID (it is a number with leading %
) which uniquely identifies the pane within the tmux server. A process running inside tmux can learn the pane ID of its pane by examining environment variable named TMUX_PANE
.
Knowing the pane ID (e.g. %4
), you can get the window ID and the session ID by asking the tmux server:
pane=%4window="$(tmux display-message -p -t "$pane" -F '#{window_id}')"session="$(tmux display-message -p -t "$pane" -F '#{session_id}')"
(but note the window may be linked to more than one session, see the already linked answer; the above code will find one session).
This ability to find the window ID and the session ID does not mean you shall not name your sessions and windows. Custom names have advantages:
- They may be descriptive.
- A specific name known in advance allows separate programs (scripts) to work reliably with the same tmux session or window.
In general proceed like this in a script:
pane="$(tmux new-session -s foo -n bar -d)"
The command (unless it fails for some reason) creates a new tmux session named foo
with one window named bar
being one big pane whose pane ID gets stored to a shell variable named pane
.
Now this script or another script or you can target the created session with -t =foo
(it may be good to start with tmux has-session -t =foo
and abort upon error). If the session has only one window, the window, then specifying -t =foo:
is enough to target the window. Additionally if the window has only one pane, the pane, then specifying -t =foo:
is enough to target the pane.
Similarly this script or another script or you can target the created window with -t =foo:=bar
. If the window has only one pane, the pane, then specifying the window is enough to target the pane.
Only this script can reliably target the created pane directly (with -t "$pane"
). I mean it can target the exact pane even if the window has been split to multiple panes, panes rearranged, moved to another window or so.