Some theory
The best I can figure out is that the local EOF is not being propagated to the remote process.
Basically yes, you can say that. In *nix EOF is not a character, it's a condition. When a program reading a file uses read() and there is nothing more to read, it gets 0 bytes and then it knows it got to the end of the file.
Reading from a terminal is somewhat different. Your ssh -tt allocates a terminal on the remote side and your remote cat reads from this terminal. The terminal is in the icanon mode. In this mode the terminal driver buffers data (normally this allows basic edits, like with Backspace), it sends data upon Enter. In your case it will interpret any \r and any \n character coming from local.txt as Enter. When there is nothing more to read, there's also no Enter, so the terminal driver sends nothing and read() of your cat keeps waiting. It makes sense because normally it's a user typing at the terminal; assuming EOF when the user does not type fast enough would be very wrong.
There is a way to make a terminal driver in icanon mode send 0 bytes. If it reads a character \004 (ASCII EOT) then it will send everything it's currently buffering (buffering while awaiting for Enter). Normally you send \004 to a terminal by typing Ctrl+d. Ctrl+d instead of Enter will make the terminal driver send the already typed line without any newline (or such) character. Ctrl+d after Ctrl+d (or Ctrl+d after Enter) will send exactly 0 bytes and then a tool like your cat will see the EOF condition and it will exit.
A basic (which does not mean "good") workaround is to send at least two \004 characters after the content of your local file:
{ cat local.txt; printf '\004\004'; } | ssh -tt …
You can read more here:
In case of ssh -tt …, catching \004 characters is not everything the terminal driver on the remote side does. It does a lot more, both to data coming from the client and to data coming to the client (printed by the remote command). Let's investigate some possibilities, obstacles and quirks.
Some practice
Preliminary notes:
All examples use ssh -tt. The assumption is you have your reasons and -tt is a must.
If you want to replicate the below examples, set remote='user@server' with values of user and server that work for you. The point is to allow you to copy-paste my commands without tinkering.
The examples don't use eval though. I don't really understand why you needed eval in the question. If it turns out you need eval to make the examples work then you'll have to tinker. Sorry. The examples work for me.
The examples show ssh that works without prompting for password and works reasonably fast.
$ denotes my prompt. # denotes a comment. A line with a command is always in the form of the prompt followed by the command. Then there's output I got (if any). Additional prompt at the end of the last line indicates the command exited; no prompt at the end of the last line indicates the command stalled. _ indicates the position of the local caret (text cursor) after the command exited or stalled.
We want to investigate the behavior of the remote terminal. There's also a local terminal. It does not interfere with input (our commands won't use it as input), but it may interfere with output. To put the local terminal aside, we may locally write to a regular file or pipe to (e.g.) od -c. I decided it would only obfuscate the examples. The existence of the local terminal does not affect the conclusions we will come to.
I will often use descriptions of keystrokes (e.g. Enter) to refer to characters we send (e.g. \n). It will not always be totally accurate (e.g. your Enter probably generates \r, not \n). I think keystrokes are more readable, interpret them more as indications of what we want the remote terminal driver to do.
These are the examples:
Let's replicate your problem:
$ printf 'Line1\nLine2' | ssh -tt "$remote" cat
Line1
Line2Line1
_
Why is there Line1 twice? Because the remote terminal echoes its input. Let's do something about it.
Let's try to suppress remote echo:
$ printf 'Line1\nLine2' | ssh -tt "$remote" 'stty -echo; cat'
Line1
Line2Line1
_
It didn't work. It's not obvious, but the reason is the remote terminal echoed its input before stty -echo reconfigured it.
Let's give some time for stty -echo:
$ { sleep 1; printf 'Line1\nLine2'; } | ssh -tt "$remote" 'stty -echo; cat'
Line1
_
Now we see Line1 came through cat, but Line2 probably didn't.
Note sleep 1 is already a hack. If the server was busy or the communication with the server was slow, sleep 1 might not suffice.
Let's "hit" Enter after Line2:
$ { sleep 1; printf 'Line1\nLine2\n'; } | ssh -tt "$remote" 'stty -echo; cat'
Line1
Line2
_
Let's send Ctrl+d instead of this Enter:
$ { sleep 1; printf 'Line1\nLine2\004'; } | ssh -tt "$remote" 'stty -echo; cat'
Line1
Line2_
Note the caret is not in the same place as before.
Let's send Ctrl+d twice:
$ { sleep 1; printf 'Line1\nLine2\004\004'; } | ssh -tt "$remote" 'stty -echo; cat'
Line1
Line2Connection to … closed.
$ _
The remote cat exited. The above is our basic workaround. Connection to … closed. (with one trailing newline character) comes from ssh itself and it's printed locally to its stderr, i.e. to the local terminal. It has nothing to do with the remote terminal. From now on we will suppress it with 2>/dev/null.
Let's send something more:
$ { sleep 1; printf 'Line1\nLine2\004\004 Line2 continues'; } | ssh -tt "$remote" 'stty -echo; cat' 2>/dev/null
Line1
Line2$ _
The double Ctrl+d really made our cat see EOF, it didn't read the rest of the input.
There are more characters that interact with the remote terminal driver. \010 (octal 10, decimal 8, ASCII BS) is like Backspace. Let's not annoy our remote cat by saying we like dogs:
$ { sleep 1; printf 'I like dogs\010\010\010\010cats.\n\004'; } | ssh -tt "$remote" 'stty -echo; cat' 2>/dev/null
I like cats.
$ _
Here we used a single Ctrl+d after Enter. This Enter is also the reason the final local prompt is in a separate line, not just after the dot.
We can send Ctrl+c (\003, ASCII ETX):
$ { sleep 1; printf 'I like...\ndogs\03'; } | ssh -tt "$remote" 'stty -echo; cat' 2>/dev/null
$ _
Despite Enter in the stream (which normally would make cat read and print I like...), the output is empty. Ctrl+c was caught by the remote terminal driver, the driver sent SIGINT and cat was terminated before it managed to print. Yes, \03 in the input really causes a signal. We will see it more clearly if we manage to set a trap in the remote shell before our local sleep 1 passes.
Trapping Ctrl+c:
$ { sleep 1; printf '\03'; } | ssh -tt "$remote" 'stty -echo; trap "echo trap" INT; sleep 2' 2>/dev/null
trap
$ _
As you can see, certain characters in the input cause certain modifications or actions on the remote side. For now we tested some characters that rarely occur in text files. You may think if your local.txt contains what people call text (letters, digits, symbols, newlines) and it's ASCII then you're safe. Well, not strictly; see the next example.
Windows line endings, i.e. \r\n.
$ # local test, for comparison
$ printf 'Line1\r\nLine2\r\nLine3'
Line1
Line2
Line3$ _
$ # actual remote test
$ { sleep 1; printf 'Line1\r\nLine2\r\nLine3'; } | ssh -tt "$remote" 'stty -echo; cat' 2>/dev/null
Line1
Line2
_
Missing Line3 and stalled command are not surprising; these empty lines may be. They appear because the terminal driver on the remote side translates every \r to \n. Instead of \r\n our cat sees \n\n, hence the additional lines.
We can turn this feature off.
Windows line endings, without translation.
$ { sleep 1; printf 'Line1\r\nLine2\r\nLine3'; } | ssh -tt "$remote" 'stty -echo -icrnl; cat' 2>/dev/null
Line1
Line2
_
This time cat got each \r character as \r. In stty there's also a setting that makes the terminal driver ignore \r. There are many settings; some affect input, some affect output. See the output of (local) stty -a, read man 1 stty. You will find (among other things) that icanon is not the only mode for a terminal and many features can be turned off.
raw mode.
$ { sleep 1; printf 'Windows\r\nSIGINT\n\003Backspace\010\nEOF\004\004\nExtra\n'; } | ssh -tt "$remote" 'stty raw -echo; cat' 2>/dev/null
Windows
SIGINT
Backspace
EOF
Extra
_
stty raw turns many features off. In the above example neither \r was translated nor \003 caused SIGINT, nor \010 removed the previous character. But also \004\004 did not work and cat stalled. In the local terminal output we cannot see non-printable characters, but all the characters went there and back as-is. You can confirm this by piping ssh to od -c (although due to buffering it may happen you will see a partial result).
It's good to know stty raw, but of course this example suffers from the original problem: cat stalls and ssh stalls.
stty raw also turns off features that affect the stream flowing from the remote to the local side. The local ssh prints this stream to its stdout.
Pulling a file. The example file is /bin/bash from the remote side. Note your /bin/bash may be a different file (in different version or whatever), so you may get different checksums.
$ # checksum on the remote, for comparison
$ ssh -tt "$remote" 'md5sum </bin/bash' 2>/dev/null
4600132e6a7ae0d451566943a9e79736 -
$ _
$ # pulling the file and calculating checksum locally
$ ssh -tt "$remote" 'stty raw; cat /bin/bash' 2>/dev/null | md5sum
4600132e6a7ae0d451566943a9e79736 -
$ _
$ # without stty raw, for comparison
$ ssh -tt "$remote" 'cat /bin/bash' 2>/dev/null | md5sum
358991d6d2182cbea143297d14d98f41 -
$ _
The first two commands show that with stty raw the copy that appeared on the local side was (almost certainly) identical to the remote original file. The last command shows stty raw is crucial, without it the local copy was different (i.e. the remote terminal driver mangled the file).
In each case no tool read from the remote terminal, the lack of EOF condition was thus not a problem.
It seems if the client needs ssh -t, using stty raw may be a good method for copying an arbitrary file from an SSH server to a client. I actually confirmed this also with a blob created from /dev/urandom, 1 GiB in size. Some observations:
- In this case there is no relevant data that can get to the remote terminal before
stty raw configures it. All the relevant data is from cat which starts after stty raw does its job. There is no need for local sleep 1 or so.
- If
stty, cat or the remote shell (or anything you added to the remote command) printed to its stderr, then what it printed would corrupt the copy. The same would happen if any remote tool printed to /dev/tty. You can redirect stderr on the remote side, but denying access to /dev/tty is not that easy.
Some ideas
If you need ssh -t, it seems in some circumstances (no collateral printing to the terminal on the remote) you can quite reliably pull an arbitrary remote file and expect the local ssh to exit automatically. You just need stty raw on the remote side.
Putting a file in a way that makes the local ssh exit is problematic. You cannot do this with stty raw, as you need the Ctrl+d mechanism to work. If you manage (and I'm not sure if it's fully possible) to configure the remote terminal driver like stty raw in every aspect except Ctrl+d, so capturing Ctrl+d is the only thing it does to the input stream, then you should be able to put any file that doesn't contain a \004 byte. Postpend \004\004 and this should ultimately make ssh exit.
More, stty eof allows you to pick a byte for this purpose. The tool understands one-byte argument or the caret notation. The latter allows you to simply use e.g. stty eof ^A (where ^ is literal ^ and A is literal A) and pick \001 as the byte.
Considering the POSIX definition of the text file, it would be best to pick \0 (null byte) and be able to put any text file. Unfortunately one cannot use a null byte in command-line arguments. In my tests stty eof ^@ didn't work.
Still, if you find another byte that does not appear in your file, you may succeed. In general, however, an arbitrary file may contain each possible byte at least once. Base64 (or similar) encoding performed on the fly at source and decoding at destination should be a reliable solution that works with the icanon mode of the remote terminal driver. Example:
$ md5sum </bin/bash
11227b11f565de042c48654a241e9d1c -
$ { sleep 1; base64 /bin/bash; printf '\004\004'; } | ssh -tt "$remote" 'stty -echo; base64 -d | md5sum' 2>/dev/null
11227b11f565de042c48654a241e9d1c -
$ _
(The checksum is different than 4600… calculated earlier because my local /bin/bash is different than my remote /bin/bash.)
Note if you want to save a file on the server side, you don't really need sleep 1 (which is anyway not a perfect solution to the issue it is supposed to solve). And in fact you don't really need stty -echo. Allowing the remote terminal driver to echo data back (partially or in full) is only a problem in terms of wasted CPU cycles and bandwidth, but doesn't break anything (locally you can >/dev/null). In the above example I used sleep 1 and stty -echo only because I wanted to see the output from the remote md5sum alone.
Different approach
I have actually solved a more general problem. See my self-answered question: ssh with separate stdin, stdout, stderr AND tty.