Skip to content

Low allocation SshCommand#1778

Open
Levi--G wants to merge 2 commits intosshnet:developfrom
Levi--G:SshCommandLite
Open

Low allocation SshCommand#1778
Levi--G wants to merge 2 commits intosshnet:developfrom
Levi--G:SshCommandLite

Conversation

@Levi--G
Copy link

@Levi--G Levi--G commented Mar 25, 2026

Low allocation SshCommand

Fixes #1776 and #1608
(includes #1777)

Problem

SshCommand currently allocates a lot of data and doesn't allow directly processing it. This is causing memory issues and GC pressure on low memory devices or when issuing many commands at the same time. A low allocation version should be essential in my opinion for a library optimized for parallelism.

Allocations

Example:

var c = client.RunCommand("for i in {1..1000} ; do echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ; done");

Allocated 298.272 bytes while the output might not even be needed. the thing to note here is that this is more than the data in itself, the utf8 data is only 70.000 bytes, somehow it is allocating this at least 4 times.
Allocations i could find:

  • Buffer in session (unavoidable i think, but might be an issue with clearing the buffer?)
  • Buffer (PipeStream) in SshCommand (avoidable)
  • Result string (in utf16 = size *2) (definitely avoidable)
    70kB *4 = 280 kB which is around what we get.

Even when clearing the pipebuffer, since a stream is not event driven it will with sudden bursts of data still enlarge the buffers and cause large allocations.

Additionally, small commands that are not even expected to give output will always allocate a minimum of 4kB of which 2kB of pipestream buffer.

Example:

var createcommands = Enumerable.Range(0, 100).Select(i => client.CreateCommand("echo a")).ToArray();

This will allocate 400kB of buffers, of which 200kB might be completely wasted if the output is never used and then still needs to get collected by the GC.

Event vs Stream

While i respect the stream approach which makes it compatible with a lot of dotnet build in systems currently we are doing:

  • Session reads data and buffers it
  • Session raises events
  • Command captures event
  • Command buffers data again in pipestream <= this could be avoided
  • Pipestream needs separate thread to read it again asynchronously and process it

This also causes other issues like #1608 where continuity is lost. The events could simply be exposed and never need to be handled.

Fire and forget

Right now the buffers are always used and filled, if you don't read them they keep growing. For long running fire and forget processes that means you deliberately need to set up 2 threads to spin and read the buffers or they will keep growing indefinitely, leading to more wasted memory (16kB stream copy buffers) and wasted cpu:

cmd.OutputStream.CopyToAsync(Stream.Null);
cmd.ExtendedOutputStream.CopyToAsync(Stream.Null);

So right now var c = client.RunCommand("..."); just cannot be used for long running processes.
The minimum for fire and forget is:

var cmd = client.CreateCommand("..."); //allocates 4kB
cmd.OutputStream.CopyToAsync(Stream.Null); //allocates 8kB + wasted cpu
cmd.ExtendedOutputStream.CopyToAsync(Stream.Null); //allocates 8kB + wasted cpu

Proposal

Breaking changes

The first method would be exposing the events in SshCommand and only allocating the buffers when used, but this would be a breaking change. Since that is to be avoided i believe this is not a good solution.

Allocation free SshCommand alternative

This is included in this pull request in the form of "SshCommandLite". It exposes the events through a user friendly eventarg allowing both directly using the bytes as well as the text.

Allocations solved

No streams are used and both the 2kB minimum and the ever growing buffer are solved. Output can still be obtained by using the events

client.CreateCommandLite("..."); only allocates 184 for SshCommandLite and the rest is Session buffer
client.RunCommandLite("for i in {1..1000} ; do echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ; done") only allocates 5kB in session buffer, nothing elsewhere

Event vs Stream

Since events are accessible, this solves continuity issues and improves efficiency due to no extra wasted cycles on buffer copies.

Fire and forget

Fire and forget is now actually possible since no output keeps growing while its not captured.

using var c = client.CreateCommandLite("");
// Optional only log errors for demonstration, normal or unsubscribed output will be ignored
c.ExtendedOutputReceived += (s, e) => {if (e.IsError) { Console.WriteLine(e.Text); } }; 
c.ExecuteAsync().ContinueWith(t=>c.Dispose()); // or await ...

I agree this is a rather "large" change you might not be willing to do, but i decided since otherwise the library just isn't usable for me i need to make my own fork either way if it is not accepted. No hard feelings if you discard it. But please do take a look at the problems shown here as they might be bothering other people as well.

Thank you and sorry for the long read.

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.

High memory allocation using SshCommand

1 participant