SSH & Shell Automation

Understanding SSH Automation

Technical Explainer no message framing EOF / CLOSE exit status
Q The Question

I use Chilkat SSH to start a remote shell by calling SendReqShell, then run a command with ChannelSendString. I call ChannelReadAndPoll to receive the output, but it never receives the full output. When I test with PuTTY I always get the full output. Why?

A The Answer

No output is being lost. Your program is returning from the read before the rest of the output has arrived, because it is deciding that the command has finished based on a signal that does not actually indicate completion. To read the full output reliably, you need a definite end‑of‑output signal — and which one is available depends on how you opened the channel.

1 — An SSH channel carries an unframed byte stream

Nothing in the stream marks where a command's output ends

When you call ChannelSendString, the bytes you pass are written to the remote shell's standard input. Output produced by the shell, and by any programs the shell runs, is sent back as a continuous stream of bytes. The SSH protocol delivers those bytes in order and without alteration, but it adds no structure to them: there is no length prefix, no record boundary, and no field that identifies which bytes belong to which command. The login banner, the shell prompt, your echoed keystrokes, and the output of every command are all interleaved in the same stream.

Because of this, the sequence "send a command, then read the result" is not a request that returns a response. ChannelSendString writes bytes in one direction; the read methods return bytes flowing in the other direction; and nothing links the two. The protocol cannot tell you where one command's output stops, because that information is never transmitted.

2 — Why the read returns too early

An idle interval does not mean the command is done

ChannelReadAndPoll, and any read method that stops after the stream goes quiet, returns once no new bytes have arrived for a specified interval. That interval is the only thing it is measuring, and it does not reliably correspond to a command finishing. A command that has completed sends no more output. A command that is still running but momentarily producing nothing — waiting on disk or network I/O, or performing a computation — also sends no more output. From the client side these two situations are identical: in both, no bytes are arriving. Whenever a running command's internal pause is longer than the poll timeout, the read returns and the remaining output arrives afterward, so your program captures only part of it.

PuTTY does not have this problem because PuTTY never determines whether a command has finished. It displays bytes as they arrive and sends the keys you press, and it takes no further action that depends on a command being complete, so it has no need to detect completion. An automated program is different: its next step depends on having the complete output, so it must detect completion, and it must do so correctly.

3 — Use a definite completion signal

Replace the timing‑based guess with an actual end signal. Two are available, and you choose between them when you open the channel.

exec

Runs a single command. The remote system reports completion through the protocol.

  • When the program exits, the server sends EOF, an exit‑status message, and channel CLOSE.
  • These are explicit events, not inferences from timing.
  • You also obtain the command's exit code.
  • stdout and stderr can be kept separate.
  • One command per channel; open a new channel for the next.

shell

Starts an interactive shell session. It relays the session and emits no per‑command completion signal.

  • Appropriate for staying logged in across steps, su/sudo, and device CLIs.
  • The shell never signals that one command finished.
  • You must define command completion yourself.
  • Prompts, banners, and echoed input are mixed into the output.
  • This is the mode used in the question, which is why the reads were short.

To run a command, use exec

Open a session channel, send the command, and receive until the server closes the channel. ChannelReceiveToClose returns when channel CLOSE is received — that is, when the command has actually exited — rather than after an idle interval. You then read the buffered output and the exit code.

// keep stderr separate from stdout (default is merged)
ssh.StderrToStdout = false;

int chan = ssh.OpenSessionChannel();
ssh.SendReqExec(chan, "ls -al /var/log");

// returns when channel CLOSE is received, i.e. when the command exits
ssh.ChannelReceiveToClose(chan);

string stdout = ssh.GetReceivedText(chan, "utf-8");
string stderr = ssh.GetReceivedStderrText(chan, "utf-8");

// the command's return code (0 = success, by convention)
if (ssh.ChannelReceivedExitStatus(chan)) {
    int exitCode = ssh.GetChannelExitStatus(chan);
}

ssh.ChannelRelease(chan);

Retrieve the results first. After a channel completes, read stdout, stderr, and exit status before making any other SSH call (including checking NumOpenChannels). A later SSH operation can finalize and discard the completed channel's buffered data.

If you need only the stdout of a single command, QuickCommand performs the open, exec, and receive sequence in one call:

string output = ssh.QuickCommand("uptime", "utf-8");

If you need an interactive session, use shell with a marker

Use a shell channel when you actually need a persistent, stateful session: staying logged in across multiple steps, responding to a sudo password prompt, or driving a device that presents its own command interface. Because the shell only relays the session, it emits no signal marking the end of an individual command, so you must define that boundary yourself. Append a unique marker to each command and read until the marker appears in the output. echo __CK_DONE__ produces a specific string that ChannelReceiveUntilMatch can wait for, so the read no longer depends on an idle interval.

int chan = ssh.OpenSessionChannel();
ssh.SendReqShell(chan);

// note the trailing \n: ChannelSendString appends NOTHING, so you
// must send the line ending, or the shell never receives a full line to run
ssh.ChannelSendString(chan, "ls -al; echo __CK_DONE__\n", "utf-8");

// read until the marker arrives; the output before it is now complete
ssh.ChannelReceiveUntilMatch(chan, "__CK_DONE__", "utf-8", true);
string output = ssh.GetReceivedText(chan, "utf-8");

Matching the shell prompt instead of a marker is possible, but only when the prompt is stable and cannot appear inside a command's own output; a marker you control avoids that risk. When a read can end in more than one valid way — for example a normal prompt or a password challenge — ChannelReceiveUntilMatchN returns on whichever occurs first.

Do not request a PTY unless a command requires one. Requesting a pseudo‑terminal with SendReqPty causes the remote side to operate as an interactive terminal: it echoes your input, prints prompts, combines stderr into stdout, and may emit ANSI color and cursor‑control sequences, all of which make the output harder to parse. Without a PTY, the shell normally runs non‑interactively with cleaner output. If a specific command does require a PTY — many sudo configurations and device CLIs do — request the terminal type dumb to suppress most of that formatting.

4 — A note on sending

ChannelSendString sends exactly the characters you provide, encoded with the charset you specify, and appends no line terminator. Sending "ls" submits the two characters l and s with no newline, so the remote shell never runs the command. Always include the terminator the remote system expects: a single \n (LF) for typical Unix and Linux shells, or \r\n (CRLF) for many network devices and other systems.