Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,16 @@ jobs:
source-url: https://nuget.pkg.github.com/nullean/index.json
env:
NUGET_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}
# .NET 11 is still in preview, so it needs an explicit preview quality install.
# Merge this into the setup-dotnet step above (and drop dotnet-quality) once .NET 11 GAs.
- uses: actions/setup-dotnet@v5
with:
dotnet-version: |
11.0.1xx
dotnet-quality: preview

- run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev
name: Install NativeAOT toolchain (for Proc.Tests.Binary, published as a native exe to speed up/de-flake process-lifecycle tests)
- run: ./build.sh build -s true
name: Build
- run: ./build.sh test -s true
Expand Down
4 changes: 2 additions & 2 deletions global.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"sdk": {
"version": "10.0.100",
"version": "11.0.100-preview.7.26381.103",
"rollForward": "latestFeature",
"allowPrerelease": false
"allowPrerelease": true
}
}
27 changes: 26 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ var args = new StartArguments("elasticsearch.bat")
```

This will attempt to send a `Control+C` into the running process console on windows first before falling back to `Process.Kill`.
Linux and OSX support for this flag is still in the works so thats why this behaviour is opt in.
On Linux and macOS this shells out to `kill -SIGINT`, unless running on .NET 11+ (see below), where it delivers the signal
in-process instead.


Dealing with `byte[]` characters might not be what you want to program against, so `ObservableProcess` allows the following as well.
Expand Down Expand Up @@ -145,8 +146,32 @@ Also note that `ObservableProcess` will yield whatever is in the buffer before O

`ObservableProcess`'s sibbling that utilizes `OutputDataReceived` and `ErrorDataReceived` and can only emit lines.

On .NET 11+ this reads through [`Process.ReadAllLinesAsync`](https://devblogs.microsoft.com/dotnet/process-api-improvements-in-dotnet-11/)
instead, which multiplexes `stdout`/`stderr` on a single thread without blocking any thread pool threads.

# .NET 11

`Proc` targets `net11.0` alongside its other target frameworks and picks up a few of the
[Process API improvements shipped in .NET 11](https://devblogs.microsoft.com/dotnet/process-api-improvements-in-dotnet-11/)
when running on that TFM. These are all opt-in/no-op elsewhere, so there's nothing to change if you stay on an older TFM.

* `SendControlC`/`SendControlCFirst` deliver `SIGINT` on Linux/macOS via `SafeProcessHandle.Signal` instead of shelling out
to the `kill` binary.
* `EventBasedObservableProcess` reads through `Process.ReadAllLinesAsync` (see above).
* Two new options on `StartArguments`/`ExecArguments`, both no-ops on older TFMs or unsupported platforms:

```csharp
var args = new StartArguments("some-long-running-tool")
{
// Kills the started process if this process exits, including crashes. Backed by Job objects on Windows
// and PR_SET_PDEATHSIG on Linux. Only takes effect on .NET 11+ on Windows or Linux.
KillOnParentExit = true,

// Restricts which handles the started process inherits, instead of every inheritable handle from this
// process. Only takes effect on .NET 11+.
InheritedHandles = new List<SafeHandle>()
};
```



Expand Down
55 changes: 55 additions & 0 deletions src/Proc/EventBasedObservableProcess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,28 @@
using System.Reactive;
using System.Reactive.Disposables;
using System.Reactive.Linq;
#if NET11_0_OR_GREATER
using System.Threading;
using System.Threading.Tasks;
#endif
using ProcNet.Extensions;
using ProcNet.Std;

namespace ProcNet
{
#if NET11_0_OR_GREATER
/// <summary>
/// This implementation reads standard output and error through <see cref="Process.ReadAllLinesAsync"/>, which
/// multiplexes both streams on a single thread without blocking any thread pool threads and is deadlock-free
/// by construction.
/// </summary>
#else
/// <summary>
/// This implementation wraps over <see cref="Process.OutputDataReceived"/> and <see cref="Process.ErrorDataReceived"/>
/// it utilizes a double call to <see cref="Process.WaitForExit()"/> once with timeout and once without to ensure all events are
/// received.
/// </summary>
#endif
public class EventBasedObservableProcess: ObservableProcessBase<LineOut>, ISubscribeLines
{
public EventBasedObservableProcess(string binary, params string[] arguments) : base(binary, arguments) { }
Expand All @@ -22,6 +34,48 @@ public EventBasedObservableProcess(StartArguments startArguments) : base(startAr
protected override IObservable<LineOut> CreateConsoleOutObservable() =>
Observable.Create<LineOut>(observer => KickOff(observer));

#if NET11_0_OR_GREATER
private CompositeDisposable KickOff(IObserver<LineOut> observer)
{
if (!StartProcess(observer)) return new CompositeDisposable();

Started = true;
var cts = new CancellationTokenSource();
// Deliberately not awaited/Task.Run'ed: ReadAllLinesAsync performs true async I/O, so running it
// inline here (rather than on a thread pool thread) is what avoids blocking a thread for the
// lifetime of the process, which is the whole point of adopting it.
_ = ReadAllLinesLoop(observer, cts.Token);

return new CompositeDisposable(Disposable.Create(() => cts.Cancel()));
}

private async Task ReadAllLinesLoop(IObserver<LineOut> observer, CancellationToken token)
{
try
{
await foreach (var line in Process.ReadAllLinesAsync(token).ConfigureAwait(false))
observer.OnNext(new LineOut(line.StandardError, line.Content));

try
{
await Process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (InvalidOperationException)
{
// Process already disposed
}
OnExit(observer);
}
catch (OperationCanceledException)
{
// Subscription disposed while still reading; Stop() already takes care of completing the observer.
}
catch (Exception e)
{
OnError(observer, e);
}
}
#else
private CompositeDisposable KickOff(IObserver<LineOut> observer)
{
var stdOut = Process.ObserveStandardOutLineByLine();
Expand Down Expand Up @@ -59,5 +113,6 @@ private IDisposable CreateProcessExitSubscription(IObservable<EventPattern<objec
}
OnExit(observer);
}, e => OnError(observer, e), ()=> OnCompleted(observer));
#endif
}
}
20 changes: 20 additions & 0 deletions src/Proc/Extensions/ObserveOutputExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,25 @@ public static Task ObserveErrorOutBuffered(this Process process, IObserver<Chara
public static Task ObserveStandardOutBuffered(this Process process, IObserver<CharactersOut> observer, int bufferSize, Func<bool> keepBuffering, CancellationToken token) =>
BufferedRead(process, process.StandardOutput, observer, bufferSize, ConsoleOut.Out, keepBuffering, token);

#if NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER
// StreamReader.ReadAsync has had a cancellable Memory<char> overload since netstandard2.1/.NET Core 2.1,
// with a documented contract that it returns 0 if and only if the stream has reached its end. That makes
// CancellableStreamReader's hand-rolled cancellation and EndOfStreamAsync (both written back when no such
// overload existed at all) unnecessary here.
private static async Task BufferedRead(Process p, StreamReader r, IObserver<CharactersOut> o, int b, Func<char[], CharactersOut> m, Func<bool> keepBuffering, CancellationToken token)
{
while (keepBuffering())
{
var buffer = new char[b];
var read = await r.ReadAsync(buffer.AsMemory(0, b), token).ConfigureAwait(true);
if (read == 0) break;

o.OnNext(m(buffer));
}

token.ThrowIfCancellationRequested();
}
#else
private static async Task BufferedRead(Process p, StreamReader r, IObserver<CharactersOut> o, int b, Func<char[], CharactersOut> m, Func<bool> keepBuffering, CancellationToken token)
{
using (var sr = new CancellableStreamReader(r.BaseStream, Encoding.UTF8, true, b, true, token))
Expand All @@ -69,6 +88,7 @@ private static async Task BufferedRead(Process p, StreamReader r, IObserver<Char

token.ThrowIfCancellationRequested();
}
#endif

public static void ReadStandardErrBlocking(this Process process, IObserver<CharactersOut> observer, int bufferSize, Func<bool> keepBuffering) =>
BufferedReadBlocking(process, process.StandardError, observer, bufferSize, ConsoleOut.ErrorOut, keepBuffering);
Expand Down
25 changes: 25 additions & 0 deletions src/Proc/ObservableProcessBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
using System.IO;
using System.Reactive.Linq;
using System.Reflection;
#if NET11_0_OR_GREATER
using System.Runtime.InteropServices;
#endif
using System.Threading;
using ProcNet.Extensions;
using ProcNet.Std;
Expand Down Expand Up @@ -166,6 +169,13 @@ private Process CreateProcess()

if (!string.IsNullOrWhiteSpace(s.WorkingDirectory)) processStartInfo.WorkingDirectory = s.WorkingDirectory;

#if NET11_0_OR_GREATER
if (s.KillOnParentExit && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()))
processStartInfo.KillOnParentExit = true;
if (s.InheritedHandles != null)
processStartInfo.InheritedHandles = s.InheritedHandles;
#endif

var p = new Process
{
EnableRaisingEvents = true,
Expand Down Expand Up @@ -235,6 +245,20 @@ public bool SendControlC(int processId)
{
lock (_sendLock)
{
#if NET11_0_OR_GREATER
// .NET 11 exposes SafeProcessHandle.Signal, letting us deliver SIGINT in-process
// instead of shelling out to the `kill` binary.
try
{
using var target = Process.GetProcessById(processId);
return target.SafeHandle.Signal(PosixSignal.SIGINT);
}
catch (ArgumentException)
{
// No process with that id is running.
return false;
}
#else
// I wish .NET Core had signals baked in but looking at the corefx repos tickets this is not happening any time soon.
var args = new StartArguments("kill", "-SIGINT", processId.ToString(CultureInfo.InvariantCulture))
{
Expand All @@ -243,6 +267,7 @@ public bool SendControlC(int processId)
};
var result = Proc.Start(args);
return result.ExitCode == 0;
#endif
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/Proc/Proc.Exec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ public static int Exec(ExecArguments arguments)
foreach (var kv in arguments.Environment)
info.Environment[kv.Key] = kv.Value;

#if NET11_0_OR_GREATER
if (arguments.KillOnParentExit && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()))
info.KillOnParentExit = true;
if (arguments.InheritedHandles != null)
info.InheritedHandles = arguments.InheritedHandles;
#endif

var printBinary = arguments.OnlyPrintBinaryInExceptionMessage
? $"\"{arguments.Binary}\""
: $"\"{arguments.Binary} {args.NaivelyQuoteArguments()}\"{(pwd == null ? string.Empty : $" pwd: {pwd}")}";
Expand Down
7 changes: 7 additions & 0 deletions src/Proc/Proc.ExecAsync.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ public static async Task<int> ExecAsync(ExecArguments arguments, CancellationTok
foreach (var kv in arguments.Environment)
info.Environment[kv.Key] = kv.Value;

#if NET11_0_OR_GREATER
if (arguments.KillOnParentExit && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()))
info.KillOnParentExit = true;
if (arguments.InheritedHandles != null)
info.InheritedHandles = arguments.InheritedHandles;
#endif

var printBinary = arguments.OnlyPrintBinaryInExceptionMessage
? $"\"{arguments.Binary}\""
: $"\"{arguments.Binary} {args.NaivelyQuoteArguments()}\"{(pwd == null ? string.Empty : $" pwd: {pwd}")}";
Expand Down
2 changes: 1 addition & 1 deletion src/Proc/Proc.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>proc</AssemblyName>
<TargetFrameworks>netstandard2.0;netstandard2.1;net461;net8.0;net10.0</TargetFrameworks>
<TargetFrameworks>netstandard2.0;netstandard2.1;net461;net8.0;net10.0;net11.0</TargetFrameworks>
<RootNamespace>ProcNet</RootNamespace>


Expand Down
19 changes: 19 additions & 0 deletions src/Proc/ProcessArgumentsBase.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;

namespace ProcNet
{
Expand All @@ -25,5 +26,23 @@ public ProcessArgumentsBase(string binary, params string[] args)
/// <summary> Force arguments and the current working director NOT to be part of the exception message </summary>
public bool OnlyPrintBinaryInExceptionMessage { get; set; }

/// <summary>
/// Ensures the started process is terminated when this process exits, including forced terminations and
/// crashes. Backed by Job objects on Windows and <c>PR_SET_PDEATHSIG</c> on Linux/Android.
/// <para>
/// Only takes effect when running on .NET 11 or greater on Windows or Linux; it is a no-op everywhere else
/// (including macOS, which the underlying runtime feature does not yet support).
/// </para>
/// </summary>
public bool KillOnParentExit { get; set; }

/// <summary>
/// Restricts which handles the started process inherits, instead of the default behaviour of inheriting
/// every inheritable handle from this process. An empty list means only the standard handles are inherited.
/// <para>Only takes effect when running on .NET 11 or greater; it is a no-op on older target frameworks.</para>
/// <para>Only <see cref="Microsoft.Win32.SafeHandles.SafeFileHandle"/> and <see cref="Microsoft.Win32.SafeHandles.SafePipeHandle"/> are supported by the underlying runtime feature.</para>
/// </summary>
public IList<SafeHandle> InheritedHandles { get; set; }

}
}
27 changes: 26 additions & 1 deletion tests/Proc.Tests.Binary/Proc.Tests.Binary.csproj
Original file line number Diff line number Diff line change
@@ -1,10 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks>net10.0;net11.0</TargetFrameworks>
<AssemblyName>Proc.Tests.Binary</AssemblyName>
<RootNamespace>Proc.Tests.Binary</RootNamespace>
<NoWarn>CS1701,CS1591</NoWarn>
<IsPackable>false</IsPackable>
<!--
PublishAot is declared here in the project file rather than passed via `-p:PublishAot=true` on the
command line. Passing it on the command line makes it a global property that leaks into every project
in the build graph, including Proc.csproj's netstandard2.0/net461 slices which don't support NativeAOT,
and MSBuild errors on those before it even picks the slice that will actually be used
(see https://github.com/dotnet/sdk/issues/30814 and https://github.com/dotnet/sdk/issues/29395).
-->
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Proc\Proc.csproj" />
</ItemGroup>

<!--
Tests invoke this project as a NativeAOT-published executable (see TestsBase.GetTestBinaryPath) rather than
via `dotnet <dll>`, to avoid dotnet-host/JIT startup overhead and variance in process-lifecycle tests. Plain
`dotnet build`/`dotnet test` only run the `Build` target, not `Publish`, so this target republishes the
NativeAOT binary after every build for the SDK's own RID. The RuntimeIdentifier=='' condition prevents this
from recursing into itself: the nested Publish invocation below runs its own inner Build with
RuntimeIdentifier set, so it won't re-enter this target.
-->
<Target Name="PublishNativeAotAfterBuild" AfterTargets="Build" Condition="'$(RuntimeIdentifier)' == '' AND '$(TargetFramework)' != ''">
<MSBuild Projects="$(MSBuildProjectFullPath)" Targets="Publish"
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(NETCoreSdkRuntimeIdentifier);SelfContained=true" />
</Target>
</Project>
Loading
Loading