From ce0beeae306b04ad4390a071b3cda634aec502d0 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 2 Sep 2026 16:14:30 +0200 Subject: [PATCH] Add net11.0 target and adopt .NET 11 Process API improvements Proc now multi-targets net11.0 and picks up a few of the Process API improvements from that release, all gated behind NET11_0_OR_GREATER and no-ops elsewhere: - SendControlC delivers SIGINT on Unix via SafeProcessHandle.Signal instead of shelling out to `kill`. - New KillOnParentExit and InheritedHandles options on StartArguments/ExecArguments, wired into ProcessStartInfo. - EventBasedObservableProcess reads through Process.ReadAllLinesAsync. Also simplifies BufferedObservableProcess's stream reading on netstandard2.1/net8.0/net10.0/net11.0, dropping the hand-rolled CancellableStreamReader now that StreamReader.ReadAsync has a native cancellable overload on those TFMs (the class is kept for netstandard2.0/net461). Proc.Tests.Binary is now published as a NativeAOT executable and invoked directly in tests instead of via `dotnet `, removing dotnet-host/JIT startup variance from process-lifecycle tests. Co-authored-by: Cursor --- .github/workflows/ci.yml | 9 +++ global.json | 4 +- readme.md | 27 ++++++++- src/Proc/EventBasedObservableProcess.cs | 55 +++++++++++++++++++ .../Extensions/ObserveOutputExtensions.cs | 20 +++++++ src/Proc/ObservableProcessBase.cs | 25 +++++++++ src/Proc/Proc.Exec.cs | 7 +++ src/Proc/Proc.ExecAsync.cs | 7 +++ src/Proc/Proc.csproj | 2 +- src/Proc/ProcessArgumentsBase.cs | 19 +++++++ .../Proc.Tests.Binary.csproj | 27 ++++++++- tests/Proc.Tests.Binary/Program.cs | 40 ++++++++++++++ tests/Proc.Tests/ControlCUnixTestCases.cs | 52 ++++++++++++++++++ tests/Proc.Tests/KillOnParentExitTests.cs | 38 +++++++++++++ tests/Proc.Tests/Proc.Tests.csproj | 2 +- tests/Proc.Tests/SkipOnWindowsFact.cs | 14 +++++ ...SkipUnlessKillOnParentExitSupportedFact.cs | 20 +++++++ tests/Proc.Tests/TestsBase.cs | 35 ++++++++---- 18 files changed, 387 insertions(+), 16 deletions(-) create mode 100644 tests/Proc.Tests/ControlCUnixTestCases.cs create mode 100644 tests/Proc.Tests/KillOnParentExitTests.cs create mode 100644 tests/Proc.Tests/SkipOnWindowsFact.cs create mode 100644 tests/Proc.Tests/SkipUnlessKillOnParentExitSupportedFact.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4107def..4c9fd67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/global.json b/global.json index fe7e453..3938459 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "10.0.100", + "version": "11.0.100-preview.7.26381.103", "rollForward": "latestFeature", - "allowPrerelease": false + "allowPrerelease": true } } \ No newline at end of file diff --git a/readme.md b/readme.md index 3753fb7..3dcea10 100644 --- a/readme.md +++ b/readme.md @@ -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. @@ -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() +}; +``` diff --git a/src/Proc/EventBasedObservableProcess.cs b/src/Proc/EventBasedObservableProcess.cs index 8ae61cd..fff262f 100644 --- a/src/Proc/EventBasedObservableProcess.cs +++ b/src/Proc/EventBasedObservableProcess.cs @@ -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 + /// + /// This implementation reads standard output and error through , which + /// multiplexes both streams on a single thread without blocking any thread pool threads and is deadlock-free + /// by construction. + /// +#else /// /// This implementation wraps over and /// it utilizes a double call to once with timeout and once without to ensure all events are /// received. /// +#endif public class EventBasedObservableProcess: ObservableProcessBase, ISubscribeLines { public EventBasedObservableProcess(string binary, params string[] arguments) : base(binary, arguments) { } @@ -22,6 +34,48 @@ public EventBasedObservableProcess(StartArguments startArguments) : base(startAr protected override IObservable CreateConsoleOutObservable() => Observable.Create(observer => KickOff(observer)); +#if NET11_0_OR_GREATER + private CompositeDisposable KickOff(IObserver 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 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 observer) { var stdOut = Process.ObserveStandardOutLineByLine(); @@ -59,5 +113,6 @@ private IDisposable CreateProcessExitSubscription(IObservable OnError(observer, e), ()=> OnCompleted(observer)); +#endif } } diff --git a/src/Proc/Extensions/ObserveOutputExtensions.cs b/src/Proc/Extensions/ObserveOutputExtensions.cs index a5991ae..e3c0c0d 100644 --- a/src/Proc/Extensions/ObserveOutputExtensions.cs +++ b/src/Proc/Extensions/ObserveOutputExtensions.cs @@ -48,6 +48,25 @@ public static Task ObserveErrorOutBuffered(this Process process, IObserver observer, int bufferSize, Func 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 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 o, int b, Func m, Func 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 o, int b, Func m, Func keepBuffering, CancellationToken token) { using (var sr = new CancellableStreamReader(r.BaseStream, Encoding.UTF8, true, b, true, token)) @@ -69,6 +88,7 @@ private static async Task BufferedRead(Process p, StreamReader r, IObserver observer, int bufferSize, Func keepBuffering) => BufferedReadBlocking(process, process.StandardError, observer, bufferSize, ConsoleOut.ErrorOut, keepBuffering); diff --git a/src/Proc/ObservableProcessBase.cs b/src/Proc/ObservableProcessBase.cs index 82d61f6..9375bbe 100644 --- a/src/Proc/ObservableProcessBase.cs +++ b/src/Proc/ObservableProcessBase.cs @@ -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; @@ -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, @@ -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)) { @@ -243,6 +267,7 @@ public bool SendControlC(int processId) }; var result = Proc.Start(args); return result.ExitCode == 0; +#endif } } } diff --git a/src/Proc/Proc.Exec.cs b/src/Proc/Proc.Exec.cs index 4431841..b6536d5 100644 --- a/src/Proc/Proc.Exec.cs +++ b/src/Proc/Proc.Exec.cs @@ -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}")}"; diff --git a/src/Proc/Proc.ExecAsync.cs b/src/Proc/Proc.ExecAsync.cs index 8302a5d..a219b0c 100644 --- a/src/Proc/Proc.ExecAsync.cs +++ b/src/Proc/Proc.ExecAsync.cs @@ -33,6 +33,13 @@ public static async Task 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}")}"; diff --git a/src/Proc/Proc.csproj b/src/Proc/Proc.csproj index 42d16f8..fcb1dbf 100644 --- a/src/Proc/Proc.csproj +++ b/src/Proc/Proc.csproj @@ -2,7 +2,7 @@ proc - netstandard2.0;netstandard2.1;net461;net8.0;net10.0 + netstandard2.0;netstandard2.1;net461;net8.0;net10.0;net11.0 ProcNet diff --git a/src/Proc/ProcessArgumentsBase.cs b/src/Proc/ProcessArgumentsBase.cs index b20daac..41c4c2c 100644 --- a/src/Proc/ProcessArgumentsBase.cs +++ b/src/Proc/ProcessArgumentsBase.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Runtime.InteropServices; namespace ProcNet { @@ -25,5 +26,23 @@ public ProcessArgumentsBase(string binary, params string[] args) /// Force arguments and the current working director NOT to be part of the exception message public bool OnlyPrintBinaryInExceptionMessage { get; set; } + /// + /// Ensures the started process is terminated when this process exits, including forced terminations and + /// crashes. Backed by Job objects on Windows and PR_SET_PDEATHSIG on Linux/Android. + /// + /// 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). + /// + /// + public bool KillOnParentExit { get; set; } + + /// + /// 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. + /// Only takes effect when running on .NET 11 or greater; it is a no-op on older target frameworks. + /// Only and are supported by the underlying runtime feature. + /// + public IList InheritedHandles { get; set; } + } } diff --git a/tests/Proc.Tests.Binary/Proc.Tests.Binary.csproj b/tests/Proc.Tests.Binary/Proc.Tests.Binary.csproj index 66309ed..22cafa9 100644 --- a/tests/Proc.Tests.Binary/Proc.Tests.Binary.csproj +++ b/tests/Proc.Tests.Binary/Proc.Tests.Binary.csproj @@ -1,10 +1,35 @@ Exe - net10.0 + net10.0;net11.0 Proc.Tests.Binary Proc.Tests.Binary CS1701,CS1591 false + + true + true + + + + + + + + \ No newline at end of file diff --git a/tests/Proc.Tests.Binary/Program.cs b/tests/Proc.Tests.Binary/Program.cs index 7bdd9f9..452b73d 100644 --- a/tests/Proc.Tests.Binary/Program.cs +++ b/tests/Proc.Tests.Binary/Program.cs @@ -4,6 +4,7 @@ using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; +using ProcNet; namespace Proc.Tests.Binary { @@ -41,6 +42,8 @@ public static async Task Main(string[] args) if (testCase == nameof(LongRunning).ToLowerInvariant()) return await LongRunning(); if (testCase == nameof(TrulyLongRunning).ToLowerInvariant()) return await TrulyLongRunning(); if (testCase == nameof(WritePidAndWait).ToLowerInvariant()) return WritePidAndWait(); + if (testCase == nameof(KillOnParentExitChild).ToLowerInvariant()) return KillOnParentExitChild(); + if (testCase == nameof(WriteChildPidAndWait).ToLowerInvariant()) return WriteChildPidAndWait(); return 1; } @@ -202,6 +205,43 @@ private static int WritePidAndWait() return 0; } + // Acts as the "middle" process for KillOnParentExit tests: starts a grandchild with + // StartArguments.KillOnParentExit = true and then exits, so the test can observe whether the + // grandchild was killed along with it. + private static int KillOnParentExitChild() + { + var pidFile = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "procnet-kill-on-parent-exit-child.txt"); + if (System.IO.File.Exists(pidFile)) System.IO.File.Delete(pidFile); + + // Environment.ProcessPath (rather than Assembly.Location, which is always empty for + // NativeAOT/single-file apps) is the path to this very executable, so it can spawn another + // instance of itself as the grandchild. + var selfExe = Environment.ProcessPath; + var childArgs = new StartArguments(selfExe, nameof(WriteChildPidAndWait)) + { + KillOnParentExit = true, + WaitForExit = null + }; + var process = new ObservableProcess(childArgs); + process.Subscribe(_ => { }); // cold observable; subscribing starts the underlying process + + // Poll for evidence the grandchild has actually started (written its PID) instead of guessing a + // fixed sleep duration, so this isn't sensitive to how long process startup takes on a given machine. + var deadline = DateTime.UtcNow.AddSeconds(10); + while (!System.IO.File.Exists(pidFile) && DateTime.UtcNow < deadline) + Thread.Sleep(25); + + return 0; + } + + private static int WriteChildPidAndWait() + { + var pidFile = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "procnet-kill-on-parent-exit-child.txt"); + System.IO.File.WriteAllText(pidFile, System.Diagnostics.Process.GetCurrentProcess().Id.ToString()); + Thread.Sleep(TimeSpan.FromSeconds(30)); + return 0; + } + private static int MoreText() { var output = @" diff --git a/tests/Proc.Tests/ControlCUnixTestCases.cs b/tests/Proc.Tests/ControlCUnixTestCases.cs new file mode 100644 index 0000000..85cc4a5 --- /dev/null +++ b/tests/Proc.Tests/ControlCUnixTestCases.cs @@ -0,0 +1,52 @@ +#if NET11_0_OR_GREATER +using System; +using System.Collections.Generic; +using FluentAssertions; + +namespace ProcNet.Tests +{ + /// + /// On .NET 11+, delivers SIGINT on + /// non-Windows platforms via SafeProcessHandle.Signal instead of shelling out to the `kill` binary. + /// These mirror , which only runs on Windows. + /// + public class ControlCUnixTestCases : TestsBase + { + [SkipOnWindowsFact] + public void ControlC() + { + var args = TestCaseArguments(nameof(ControlC)); + args.SendControlCFirst = true; + + var process = new ObservableProcess(args); + var seen = new List(); + process.SubscribeLines(c => seen.Add(c.Line)); + process.WaitForCompletion(TimeSpan.FromSeconds(5)); + + seen.Should().NotBeEmpty().And.HaveCount(2, string.Join(Environment.NewLine, seen)); + seen[0].Should().Be("Written before control+c"); + seen[1].Should().Be("Written after control+c"); + } + + [SkipOnWindowsFact] + public void ControlCSend() + { + var args = TestCaseArguments(nameof(ControlC)); + args.SendControlCFirst = true; + + var process = new ObservableProcess(args); + var seen = new List(); + process.SubscribeLines(c => + { + seen.Add(c.Line); + if (c.Line.Contains("before")) process.SendControlC(); + }); + process.WaitForCompletion(TimeSpan.FromSeconds(5)); + + seen.Should().NotBeEmpty().And.HaveCount(2, string.Join(Environment.NewLine, seen)); + seen[0].Should().Be("Written before control+c"); + seen[1].Should().Be("Written after control+c"); + } + } +} +#endif diff --git a/tests/Proc.Tests/KillOnParentExitTests.cs b/tests/Proc.Tests/KillOnParentExitTests.cs new file mode 100644 index 0000000..7dc32a5 --- /dev/null +++ b/tests/Proc.Tests/KillOnParentExitTests.cs @@ -0,0 +1,38 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using FluentAssertions; + +namespace ProcNet.Tests +{ + public class KillOnParentExitTests : TestsBase + { + [SkipUnlessKillOnParentExitSupportedFact] + public void KillOnParentExit_KillsGrandchildWhenMiddleProcessExits() + { + var pidFile = Path.Combine(Path.GetTempPath(), "procnet-kill-on-parent-exit-child.txt"); + if (File.Exists(pidFile)) File.Delete(pidFile); + + // KillOnParentExitChild starts a grandchild process with StartArguments.KillOnParentExit = true + // and then exits itself; the grandchild should be terminated as a result. + var args = ExecTestCaseArguments("KillOnParentExitChild"); + args.Timeout = TimeSpan.FromSeconds(15); + Proc.Exec(args); + + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!File.Exists(pidFile) && DateTime.UtcNow < deadline) + Thread.Sleep(50); + + File.Exists(pidFile).Should().BeTrue("the grandchild process should have written its PID before sleeping"); + var pid = int.Parse(File.ReadAllText(pidFile)); + + // Give the OS a moment to register the kill. + Thread.Sleep(500); + + Action check = () => Process.GetProcessById(pid); + check.Should().Throw( + "the grandchild should have been killed when its parent (the middle process) exited"); + } + } +} diff --git a/tests/Proc.Tests/Proc.Tests.csproj b/tests/Proc.Tests/Proc.Tests.csproj index 5bcc785..b13a672 100644 --- a/tests/Proc.Tests/Proc.Tests.csproj +++ b/tests/Proc.Tests/Proc.Tests.csproj @@ -1,6 +1,6 @@  - net10.0 + net10.0;net11.0 Proc.Tests ProcNet.Tests false diff --git a/tests/Proc.Tests/SkipOnWindowsFact.cs b/tests/Proc.Tests/SkipOnWindowsFact.cs new file mode 100644 index 0000000..f780286 --- /dev/null +++ b/tests/Proc.Tests/SkipOnWindowsFact.cs @@ -0,0 +1,14 @@ +using System.Runtime.InteropServices; +using Xunit; + +namespace ProcNet.Tests +{ + public sealed class SkipOnWindowsFact : FactAttribute + { + public SkipOnWindowsFact() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + Skip = "Skipped, this test can only run on non-windows platforms"; + } + } +} diff --git a/tests/Proc.Tests/SkipUnlessKillOnParentExitSupportedFact.cs b/tests/Proc.Tests/SkipUnlessKillOnParentExitSupportedFact.cs new file mode 100644 index 0000000..110a9b9 --- /dev/null +++ b/tests/Proc.Tests/SkipUnlessKillOnParentExitSupportedFact.cs @@ -0,0 +1,20 @@ +using System; +using Xunit; + +namespace ProcNet.Tests +{ + /// + /// is only wired up on .NET 11+, and even there the underlying + /// runtime feature only supports Windows and Linux (not macOS) as of .NET 11 Preview 7. + /// + public sealed class SkipUnlessKillOnParentExitSupportedFact : FactAttribute + { + public SkipUnlessKillOnParentExitSupportedFact() + { +#if NET11_0_OR_GREATER + if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()) return; +#endif + Skip = "Skipped, KillOnParentExit requires .NET 11+ on Windows or Linux"; + } + } +} diff --git a/tests/Proc.Tests/TestsBase.cs b/tests/Proc.Tests/TestsBase.cs index 4bc707d..a197dfa 100644 --- a/tests/Proc.Tests/TestsBase.cs +++ b/tests/Proc.Tests/TestsBase.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.InteropServices; namespace ProcNet.Tests { @@ -26,8 +27,11 @@ protected static string GetWorkingDir() return binaryFolder; } + // Test cases run against a NativeAOT-published executable rather than `dotnet `, to avoid + // dotnet-host/JIT startup overhead and variance in tests that depend on process lifecycle timing. + // See Proc.Tests.Binary.csproj's PublishNativeAotAfterBuild target, which republishes it on every build. protected static StartArguments CmdTestCaseArguments(string testcase, params string[] args) { - string[] arguments = ["/C", "dotnet", GetDll(), testcase]; + string[] arguments = ["/C", GetTestBinaryPath(), testcase]; return new StartArguments("cmd", arguments.Concat(args)) { WorkingDirectory = GetWorkingDir(), @@ -37,9 +41,9 @@ protected static StartArguments CmdTestCaseArguments(string testcase, params str protected static StartArguments TestCaseArguments(string testcase, params string[] args) { - string[] arguments = [GetDll(), testcase]; + string[] arguments = [testcase]; - return new StartArguments("dotnet", arguments.Concat(args)) + return new StartArguments(GetTestBinaryPath(), arguments.Concat(args)) { WorkingDirectory = GetWorkingDir(), Timeout = WaitTimeout @@ -48,27 +52,38 @@ protected static StartArguments TestCaseArguments(string testcase, params string protected static ExecArguments ExecTestCaseArguments(string testcase, params string[] args) { - string[] arguments = [GetDll(), testcase]; - return new ExecArguments("dotnet", arguments.Concat(args)) + string[] arguments = [testcase]; + return new ExecArguments(GetTestBinaryPath(), arguments.Concat(args)) { WorkingDirectory = GetWorkingDir() }; } protected static LongRunningArguments LongRunningTestCaseArguments(string testcase) => - new("dotnet", GetDll(), testcase) + new(GetTestBinaryPath(), testcase) { WorkingDirectory = GetWorkingDir(), Timeout = WaitTimeout }; - protected static string GetDll() + protected static string GetTestBinaryPath() { - var dll = Path.Combine("bin", GetRunningConfiguration(), "net10.0", _procTestBinary + ".dll"); - var fullPath = Path.Combine(GetWorkingDir(), dll); +#if NET11_0_OR_GREATER + const string tfm = "net11.0"; +#else + const string tfm = "net10.0"; +#endif + var rid = RuntimeInformation.RuntimeIdentifier; + var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? _procTestBinary + ".exe" : _procTestBinary; + var exe = Path.Combine("bin", GetRunningConfiguration(), tfm, rid, "publish", exeName); + var fullPath = Path.Combine(GetWorkingDir(), exe); if (!File.Exists(fullPath)) throw new Exception($"Can not find {fullPath}"); - return dll; + // Unlike `dotnet ` (where dotnet itself resolves the dll relative to its own, + // already-started, working directory), the executable path passed to Process.Start is resolved + // relative to *this* process's cwd, not the child's ProcessStartInfo.WorkingDirectory. So this must + // be absolute. + return fullPath; } private static string GetRunningConfiguration()