From 30dd0ddbd4b85c99c7b5989fb0eda12da48ef3cb Mon Sep 17 00:00:00 2001 From: uniblab Date: Sun, 30 Aug 2026 17:53:24 -0400 Subject: [PATCH 1/3] 1.2.0 native process runner --- Icod.Processes.csproj | 6 +- README.md | 14 +- src/PosixFileDescriptorMutationScope.cs | 215 ++++++++++++++++++ src/ProcessNative.cs | 34 +++ src/ProcessRunOptions.cs | 25 +- src/ProcessRunner.cs | 183 +++++++++++++++ .../Icod.Processes.ProcessTestHost.csproj | 3 + tests/ProcessTestHost/Program.cs | 72 ++++++ .../Processes.Tests/src/ProcessRunnerTests.cs | 183 ++++++++++++++- 9 files changed, 715 insertions(+), 20 deletions(-) create mode 100644 src/PosixFileDescriptorMutationScope.cs diff --git a/Icod.Processes.csproj b/Icod.Processes.csproj index 5a2c151..a519e50 100644 --- a/Icod.Processes.csproj +++ b/Icod.Processes.csproj @@ -12,7 +12,7 @@ Icod.Processes Icod.Processes Debug;Release;Staging - 1.1.0 + 1.2.0 AnyCPU @@ -47,8 +47,8 @@ CS1591 - 1.1.0 - Adds atomic POSIX child file-descriptor duplication for wrapper commands while preserving the 1.0 execution and control contracts. + 1.2.0 + Adds opt-in POSIX current-process replacement with reversible descriptor actions and execvp-compatible executable-text fallback. Timothy J. Bruce Cross-platform .NET process execution and control primitives for safe child launching, process identity, signals, priorities, liveness, waiting, cancellation, and timeouts. README.md diff --git a/README.md b/README.md index 0de5216..3ca86fa 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,13 @@ originally incubated under `Icod.CommandFramework.Processes`. - Linux signal disposition and blocked-mask observations; - POSIX queued signal delivery for individual processes; - POSIX nice-value operations and Windows priority-class substitutions; -- POSIX launch-time signal disposition/mask policy; and -- atomic POSIX child process-group creation when the native launch path is used. +- POSIX launch-time signal disposition/mask policy; +- atomic POSIX child process-group creation when the native launch path is used; and +- opt-in POSIX current-process replacement with reversible descriptor actions and execvp-compatible executable-text fallback. ## Requirements -The current `1.1.0` release targets .NET 10.0. The implementation uses process +The current `1.2.0` release targets .NET 10.0. The implementation uses process launch capabilities provided by the .NET 10 runtime and intentionally does not add compatibility shims for older target frameworks. @@ -39,13 +40,13 @@ The only runtime package dependency is `Icod.Timing` 1.0.0. ## Installation ```text -Install-Package Icod.Processes -Version 1.1.0 +Install-Package Icod.Processes -Version 1.2.0 ``` or: ```text -dotnet add package Icod.Processes --version 1.1.0 +dotnet add package Icod.Processes --version 1.2.0 ``` ## Example @@ -80,6 +81,7 @@ operations explicitly rather than fabricating Unix semantics. | New process group at child launch | Yes | Yes | Yes | | Custom native `argv[0]` | Unsupported | Yes | Yes | | Native child file-descriptor duplication | Unsupported | Yes | Yes | +| Current-process replacement (`execve` with descriptor actions and shell fallback) | Unsupported | Yes | Yes | | Process-group target control | Unsupported | Yes | Yes | | Signal delivery | Termination substitution | Native | Native | | Signal disposition observation | Unsupported | Yes | Unsupported | @@ -98,7 +100,7 @@ can migrate without taking a dependency on ProcPs or CoreUtils. Replace the package dependency with: ```xml - + ``` and replace: diff --git a/src/PosixFileDescriptorMutationScope.cs b/src/PosixFileDescriptorMutationScope.cs new file mode 100644 index 0000000..1c40be1 --- /dev/null +++ b/src/PosixFileDescriptorMutationScope.cs @@ -0,0 +1,215 @@ +namespace Icod.Processes; + +using System.Runtime.InteropServices; + +/// +/// Applies reversible POSIX descriptor mutations for current-process replacement and restores them when exec fails. +/// +internal sealed class PosixFileDescriptorMutationScope : IDisposable { + private readonly List _preserved = []; + private bool _disposed; + + /// Applies the requested descriptor state and returns a reversible scope. + internal static PosixFileDescriptorMutationScope Enter( + IList duplications, + bool unreadableStandardInput = false + ) { + ArgumentNullException.ThrowIfNull( duplications ); + var scope = new PosixFileDescriptorMutationScope(); + if ( 0 == duplications.Count && !unreadableStandardInput ) { + return scope; + } + if ( OperatingSystem.IsWindows() ) { + throw new PlatformNotSupportedException( + "POSIX descriptor mutation is unavailable on Windows." + ); + } + try { + scope.PreserveDescriptors( + duplications, + unreadableStandardInput + ); + if ( unreadableStandardInput ) { + scope.ApplyUnreadableStandardInput(); + } + scope.Apply( + duplications + ); + return scope; + } catch { + scope.Dispose(); + throw; + } + } + + /// + public void Dispose() { + if ( this._disposed ) { + return; + } + this._disposed = true; + for ( var index = this._preserved.Count - 1; 0 <= index; index-- ) { + var preserved = this._preserved[ index ]; + if ( preserved.WasOpen ) { + _ = ProcessNative.Dup2( + preserved.BackupDescriptor, + preserved.Descriptor + ); + _ = ProcessNative.Close( + preserved.BackupDescriptor + ); + } else { + _ = ProcessNative.Close( + preserved.Descriptor + ); + } + } + this._preserved.Clear(); + } + + private void PreserveDescriptors( + IList duplications, + bool unreadableStandardInput + ) { + var descriptorSet = new HashSet(); + foreach ( var duplication in duplications ) { + descriptorSet.Add( + duplication.SourceDescriptor + ); + descriptorSet.Add( + duplication.DestinationDescriptor + ); + } + if ( unreadableStandardInput ) { + descriptorSet.Add( 0 ); + } + var descriptors = descriptorSet + .OrderBy( + static descriptor => descriptor + ) + .ToArray(); + if ( 0 == descriptors.Length ) { + return; + } + if ( int.MaxValue == descriptors[ ^1 ] ) { + throw new ArgumentOutOfRangeException( + nameof( duplications ), + "Descriptor values leave no room for temporary preservation handles." + ); + } + var minimumBackupDescriptor = descriptors[ ^1 ] + 1; + foreach ( var descriptor in descriptors ) { + var backup = ProcessNative.Fcntl( + descriptor, + ProcessNative.DuplicateFileDescriptor, + minimumBackupDescriptor + ); + if ( 0 > backup ) { + var error = Marshal.GetLastPInvokeError(); + if ( ProcessNative.BadFileDescriptor == error ) { + this._preserved.Add( + new PreservedDescriptor( + descriptor, + -1, + false + ) + ); + continue; + } + throw new InvalidOperationException( + $"Unable to preserve file descriptor {descriptor} (errno {error})." + ); + } + if ( 0 > ProcessNative.Fcntl( + backup, + ProcessNative.SetFileDescriptorFlags, + ProcessNative.CloseOnExec + ) ) { + var error = Marshal.GetLastPInvokeError(); + _ = ProcessNative.Close( + backup + ); + throw new InvalidOperationException( + $"Unable to protect preserved file descriptor {descriptor} from exec inheritance (errno {error})." + ); + } + this._preserved.Add( + new PreservedDescriptor( + descriptor, + backup, + true + ) + ); + if ( int.MaxValue == backup ) { + throw new InvalidOperationException( + "No descriptor number remains for launch-state preservation." + ); + } + minimumBackupDescriptor = backup + 1; + } + } + + private void ApplyUnreadableStandardInput() { + var nullDescriptor = ProcessNative.Open( + "/dev/null", + ProcessNative.OpenWriteOnly + ); + if ( 0 > nullDescriptor ) { + var error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException( + $"Unable to open /dev/null for replacement standard input (errno {error})." + ); + } + if ( 0 == nullDescriptor ) { + return; + } + try { + if ( 0 > ProcessNative.Dup2( + nullDescriptor, + 0 + ) ) { + var error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException( + $"Unable to replace standard input before exec (errno {error})." + ); + } + } finally { + _ = ProcessNative.Close( + nullDescriptor + ); + } + } + + private void Apply( + IList duplications + ) { + foreach ( var duplication in duplications ) { + if ( 0 > ProcessNative.Dup2( + duplication.SourceDescriptor, + duplication.DestinationDescriptor + ) ) { + var error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException( + $"Unable to duplicate file descriptor {duplication.SourceDescriptor} onto {duplication.DestinationDescriptor} (errno {error})." + ); + } + if ( duplication.CloseSource + && duplication.SourceDescriptor != duplication.DestinationDescriptor + && 0 != ProcessNative.Close( + duplication.SourceDescriptor + ) + ) { + var error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException( + $"Unable to close duplicated source descriptor {duplication.SourceDescriptor} (errno {error})." + ); + } + } + } + + private readonly record struct PreservedDescriptor( + int Descriptor, + int BackupDescriptor, + bool WasOpen + ); +} diff --git a/src/ProcessNative.cs b/src/ProcessNative.cs index 3357550..38cd510 100644 --- a/src/ProcessNative.cs +++ b/src/ProcessNative.cs @@ -14,6 +14,10 @@ internal static class ProcessNative { internal const int NoSuchProcess = 3; /// Gets the POSIX no-such-file error number. internal const int NoSuchFile = 2; + /// Gets the POSIX executable-format error number. + internal const int ExecFormatError = 8; + /// Gets the POSIX bad-file-descriptor error number. + internal const int BadFileDescriptor = 9; /// Gets the POSIX access-denied error number. internal const int AccessDenied = 13; /// Gets the POSIX invalid-argument error number. @@ -25,6 +29,12 @@ internal static class ProcessNative { /// Gets the POSIX write-only open flag. internal const int OpenWriteOnly = 1; + /// Gets the POSIX fcntl command that duplicates a descriptor at or above a minimum value. + internal const int DuplicateFileDescriptor = 0; + /// Gets the POSIX fcntl command that sets descriptor flags. + internal const int SetFileDescriptorFlags = 2; + /// Gets the POSIX close-on-exec descriptor flag. + internal const int CloseOnExec = 1; /// Represents the POSIX union sigval used by sigqueue(3). [StructLayout( LayoutKind.Explicit )] @@ -87,6 +97,18 @@ internal static extern int Close( int descriptor ); + /// Reads or changes one POSIX file descriptor through fcntl(2). + [DllImport( + "libc", + EntryPoint = "fcntl", + SetLastError = true + )] + internal static extern int Fcntl( + int descriptor, + int command, + int argument + ); + /// Invokes POSIX kill. [DllImport( "libc", @@ -291,6 +313,18 @@ internal static extern int PosixSpawn( IntPtr environment ); + /// Replaces the current POSIX process image using exact argument and environment vectors. + [DllImport( + "libc", + EntryPoint = "execve", + SetLastError = true + )] + internal static extern int ExecVe( + IntPtr path, + IntPtr arguments, + IntPtr environment + ); + /// Waits for or polls one POSIX child process. [DllImport( "libc", diff --git a/src/ProcessRunOptions.cs b/src/ProcessRunOptions.cs index 46f36bb..920af5b 100644 --- a/src/ProcessRunOptions.cs +++ b/src/ProcessRunOptions.cs @@ -139,6 +139,23 @@ public bool ResolveExecutable { set; } + /// + /// Gets or sets whether POSIX execution replaces the current process image instead of creating a child. + /// + /// + /// On successful replacement, + /// does not return. This capability is unavailable on Windows and cannot be combined with managed + /// standard-stream redirection or capture, a new process group, an execution timeout, or a + /// callback. Ordered are + /// applied to the current process immediately before replacement and restored if exec fails. + /// Executable text that returns ENOEXEC is retried through /bin/sh, matching the + /// traditional execvp contract. + /// + public bool ReplaceCurrentProcess { + get; + set; + } + /// Gets or sets whether launch failures are returned instead of thrown. public bool ReturnLaunchFailureResult { get; @@ -164,11 +181,13 @@ public Stream? StandardOutput { } /// - /// Gets ordered POSIX child file-descriptor duplications applied atomically at spawn time. + /// Gets ordered POSIX file-descriptor duplications applied at native launch or current-process replacement. /// /// - /// Adding an item selects the native POSIX launcher. This capability is unsupported on Windows - /// and cannot be combined with managed standard-stream redirection or output capture. + /// Adding an item selects the native POSIX launcher. With , actions + /// are applied in order to the current process immediately before exec and restored if exec fails. + /// This capability is unsupported on Windows and cannot be combined with managed standard-stream + /// redirection or output capture. /// public IList PosixFileDescriptorDuplications { get; diff --git a/src/ProcessRunner.cs b/src/ProcessRunner.cs index bb7084f..2741470 100644 --- a/src/ProcessRunner.cs +++ b/src/ProcessRunner.cs @@ -171,6 +171,15 @@ or UnauthorizedAccessException } executable = located.Value!; } + if ( options.ReplaceCurrentProcess ) { + return this.RunWithPosixExec( + options, + executable, + environment, + startedTimestamp, + cancellationToken + ); + } if ( null != options.ArgumentZero || 0 < options.PosixFileDescriptorDuplications.Count || ( options.CreateProcessGroup && !OperatingSystem.IsWindows() ) @@ -438,6 +447,165 @@ await WaitForExitAfterTerminationAsync( } } + private ProcessResult RunWithPosixExec( + ProcessRunOptions options, + string executable, + ProcessEnvironment environment, + long startedTimestamp, + CancellationToken cancellationToken + ) { + if ( OperatingSystem.IsWindows() ) { + return this.HandlePosixExecSetupFailure( + options, + startedTimestamp, + "Current-process replacement is unavailable on Windows." + ); + } + if ( null != options.StandardInput + || null != options.StandardOutput + || null != options.StandardError + || options.CaptureStandardOutput + || options.CaptureStandardError + ) { + return this.HandlePosixExecSetupFailure( + options, + startedTimestamp, + "Current-process replacement requires inherited standard streams." + ); + } + if ( options.CreateProcessGroup ) { + return this.HandlePosixExecSetupFailure( + options, + startedTimestamp, + "Current-process replacement cannot create a child process group." + ); + } + if ( null != options.Timeout ) { + return this.HandlePosixExecSetupFailure( + options, + startedTimestamp, + "Current-process replacement cannot be supervised by a managed timeout." + ); + } + if ( null != options.ProcessStarted ) { + return this.HandlePosixExecSetupFailure( + options, + startedTimestamp, + "Current-process replacement does not create a child identity callback." + ); + } + if ( cancellationToken.IsCancellationRequested ) { + return new ProcessResult( + false, + null, + ProcessTermination.Canceled(), + this._clock.GetElapsedTime( + startedTimestamp, + this._clock.GetTimestamp() + ), + null, + null + ); + } + + int execError; + try { + using var path = new Utf8NativeString( executable ); + var argumentValues = new List( options.Arguments.Count + 1 ) { + options.ArgumentZero ?? executable + }; + argumentValues.AddRange( options.Arguments ); + using var arguments = new Utf8NativeStringVector( argumentValues ); + using var environmentVector = new Utf8NativeStringVector( + environment.Variables.Select( + static pair => string.Concat( pair.Key, "=", pair.Value ) + ) + ); + lock ( PosixSpawnWorkingDirectorySync ) { + var previousDirectory = Environment.CurrentDirectory; + try { + if ( null != options.WorkingDirectory ) { + Directory.SetCurrentDirectory( options.WorkingDirectory ); + } + using var signalScope = PosixProcessLaunchScope.Enter( + options.SignalPolicy + ); + using var descriptorScope = PosixFileDescriptorMutationScope.Enter( + options.PosixFileDescriptorDuplications, + options.UseUnreadableStandardInput + ); + _ = ProcessNative.ExecVe( + path.Pointer, + arguments.Pointer, + environmentVector.Pointer + ); + execError = Marshal.GetLastPInvokeError(); + if ( ProcessNative.ExecFormatError == execError ) { + const string shell = "/bin/sh"; + using var shellPath = new Utf8NativeString( shell ); + var shellArgumentValues = new List( options.Arguments.Count + 2 ) { + shell, + executable + }; + shellArgumentValues.AddRange( options.Arguments ); + using var shellArguments = new Utf8NativeStringVector( + shellArgumentValues + ); + _ = ProcessNative.ExecVe( + shellPath.Pointer, + shellArguments.Pointer, + environmentVector.Pointer + ); + execError = Marshal.GetLastPInvokeError(); + } + } finally { + if ( !string.Equals( + Environment.CurrentDirectory, + previousDirectory, + StringComparison.Ordinal + ) ) { + Directory.SetCurrentDirectory( previousDirectory ); + } + } + } + } catch ( Exception exception ) when ( + exception is ArgumentException + or DirectoryNotFoundException + or IOException + or InvalidOperationException + or PlatformNotSupportedException + or UnauthorizedAccessException + ) { + return this.HandlePosixExecSetupFailure( + options, + startedTimestamp, + exception.Message + ); + } + + var failureKind = ProcessNative.NoSuchFile == execError + ? ProcessLaunchFailureKind.NotFound + : ProcessLaunchFailureKind.CannotInvoke + ; + var message = $"Unable to replace the current process with '{executable}' (errno {execError})."; + if ( !options.ReturnLaunchFailureResult ) { + if ( ProcessLaunchFailureKind.NotFound == failureKind ) { + throw new FileNotFoundException( + message, + executable + ); + } + throw new InvalidOperationException( + message + ); + } + return this.CreateLaunchFailure( + startedTimestamp, + message, + failureKind + ); + } + private async Task RunWithPosixSpawnAsync( ProcessRunOptions options, string executable, @@ -620,6 +788,21 @@ or UnauthorizedAccessException } } + private ProcessResult HandlePosixExecSetupFailure( + ProcessRunOptions options, + long startedTimestamp, + string message + ) { + if ( !options.ReturnLaunchFailureResult ) { + throw new PlatformNotSupportedException( message ); + } + return this.CreateLaunchFailure( + startedTimestamp, + message, + ProcessLaunchFailureKind.SetupFailed + ); + } + private ProcessResult HandlePosixSpawnSetupFailure( ProcessRunOptions options, long startedTimestamp, diff --git a/tests/ProcessTestHost/Icod.Processes.ProcessTestHost.csproj b/tests/ProcessTestHost/Icod.Processes.ProcessTestHost.csproj index 65dd392..bd19d91 100644 --- a/tests/ProcessTestHost/Icod.Processes.ProcessTestHost.csproj +++ b/tests/ProcessTestHost/Icod.Processes.ProcessTestHost.csproj @@ -34,4 +34,7 @@ true CS1591 + + + diff --git a/tests/ProcessTestHost/Program.cs b/tests/ProcessTestHost/Program.cs index f72229c..f17a4c7 100644 --- a/tests/ProcessTestHost/Program.cs +++ b/tests/ProcessTestHost/Program.cs @@ -1,7 +1,9 @@ namespace Icod.Processes.ProcessTestHost; +using System.Globalization; using System.Runtime.InteropServices; using System.Text; +using Icod.Processes; /// /// Provides deterministic child-process behaviors used by Icod.Processes.Tests. @@ -96,6 +98,18 @@ await Console.Error.WriteLineAsync( ); return 0; + case "pid-file": + if ( 2 > args.Length ) { + return 3; + } + await File.WriteAllTextAsync( + args[ 1 ], + Environment.ProcessId.ToString( + CultureInfo.InvariantCulture + ) + ).ConfigureAwait( false ); + return 0; + case "process-group-file": if ( OperatingSystem.IsWindows() || 2 > args.Length ) { return 3; @@ -110,6 +124,64 @@ await File.WriteAllTextAsync( ).ConfigureAwait( false ); return 0; + case "replace": { + if ( OperatingSystem.IsWindows() || 2 > args.Length ) { + return 3; + } + var options = new ProcessRunOptions( + args[ 1 ] + ) { + ReplaceCurrentProcess = true, + ResolveExecutable = true, + ReturnLaunchFailureResult = true + }; + for ( var index = 2; index < args.Length; index++ ) { + options.Arguments.Add( + args[ index ] + ); + } + var result = await ProcessRunner.RunAsync( + options + ).ConfigureAwait( false ); + return result.Termination.ToPortableExitCode(); + } + + case "replace-output": { + if ( OperatingSystem.IsWindows() || 4 > args.Length ) { + return 3; + } + await using var output = new FileStream( + args[ 2 ], + FileMode.CreateNew, + FileAccess.Write, + FileShare.Read | FileShare.Write | FileShare.Delete + ); + var descriptor = output.SafeFileHandle.DangerousGetHandle().ToInt32(); + var options = new ProcessRunOptions( + args[ 1 ] + ) { + ReplaceCurrentProcess = true, + ResolveExecutable = true, + ReturnLaunchFailureResult = true + }; + options.PosixFileDescriptorDuplications.Add( + new PosixFileDescriptorDuplication( + descriptor, + 1, + closeSource: true + ) + ); + for ( var index = 3; index < args.Length; index++ ) { + options.Arguments.Add( + args[ index ] + ); + } + var result = await ProcessRunner.RunAsync( + options + ).ConfigureAwait( false ); + return result.Termination.ToPortableExitCode(); + } + case "sleep": await Task.Delay( 1 < args.Length diff --git a/tests/Processes.Tests/src/ProcessRunnerTests.cs b/tests/Processes.Tests/src/ProcessRunnerTests.cs index 77d8e6e..d70cee9 100644 --- a/tests/Processes.Tests/src/ProcessRunnerTests.cs +++ b/tests/Processes.Tests/src/ProcessRunnerTests.cs @@ -145,6 +145,169 @@ public async Task PosixNativeLaunchAcceptsCustomArgumentZero() { Assert.Equal( 0, result.ExitCode ); } + /// Verifies POSIX current-process replacement preserves the process identity. + [Fact] + public async Task PosixExecReplacementPreservesProcessIdentity() { + if ( OperatingSystem.IsWindows() ) { + return; + } + + var observationPath = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"icod-processes-exec-{Guid.NewGuid():N}" + ); + try { + var host = GetNativeProcessTestHostPath(); + var options = CreateNativeHostOptions( + "replace", + host, + "pid-file", + observationPath + ); + ProcessIdentity? startedIdentity = null; + options.ProcessStarted = identity => startedIdentity = identity; + + var result = await ProcessRunner.RunAsync( + options + ); + + Assert.True( result.Started ); + Assert.Equal( 0, result.ExitCode ); + Assert.NotNull( startedIdentity ); + var replacementProcessId = int.Parse( + await File.ReadAllTextAsync( + observationPath + ), + System.Globalization.CultureInfo.InvariantCulture + ); + Assert.Equal( + startedIdentity.ProcessId, + replacementProcessId + ); + } finally { + if ( File.Exists( observationPath ) ) { + File.Delete( + observationPath + ); + } + } + } + + /// Verifies POSIX replacement preserves execvp-style shell fallback for executable text. + [Fact] + public async Task PosixExecReplacementFallsBackToShellForExecutableText() { + if ( OperatingSystem.IsWindows() ) { + return; + } + + var directory = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"icod-processes-exec-shell-{Guid.NewGuid():N}" + ); + Directory.CreateDirectory( + directory + ); + var scriptPath = System.IO.Path.Combine( + directory, + "command" + ); + var observationPath = System.IO.Path.Combine( + directory, + "pid" + ); + try { + await File.WriteAllTextAsync( + scriptPath, + "printf '%s' \"$$\" > \"$1\"" + ); + File.SetUnixFileMode( + scriptPath, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + ); + + var options = CreateNativeHostOptions( + "replace", + scriptPath, + observationPath + ); + ProcessIdentity? startedIdentity = null; + options.ProcessStarted = identity => startedIdentity = identity; + + var result = await ProcessRunner.RunAsync( + options + ); + + Assert.True( result.Started ); + Assert.Equal( 0, result.ExitCode ); + Assert.NotNull( startedIdentity ); + var replacementProcessId = int.Parse( + await File.ReadAllTextAsync( + observationPath + ), + System.Globalization.CultureInfo.InvariantCulture + ); + Assert.Equal( + startedIdentity.ProcessId, + replacementProcessId + ); + } finally { + Directory.Delete( + directory, + true + ); + } + } + + /// Verifies POSIX replacement applies ordered descriptor actions without changing process identity. + [Fact] + public async Task PosixExecReplacementAppliesFileDescriptorDuplications() { + if ( OperatingSystem.IsWindows() ) { + return; + } + + var outputPath = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"icod-processes-exec-fd-{Guid.NewGuid():N}" + ); + try { + var host = GetNativeProcessTestHostPath(); + var options = CreateNativeHostOptions( + "replace-output", + host, + outputPath, + "pid" + ); + ProcessIdentity? startedIdentity = null; + options.ProcessStarted = identity => startedIdentity = identity; + + var result = await ProcessRunner.RunAsync( + options + ); + + Assert.True( result.Started ); + Assert.Equal( 0, result.ExitCode ); + Assert.NotNull( startedIdentity ); + var replacementProcessId = int.Parse( + await File.ReadAllTextAsync( + outputPath + ), + System.Globalization.CultureInfo.InvariantCulture + ); + Assert.Equal( + startedIdentity.ProcessId, + replacementProcessId + ); + } finally { + if ( File.Exists( outputPath ) ) { + File.Delete( + outputPath + ); + } + } + } + /// Verifies that POSIX process-group creation makes the child its group leader. [Fact] public async Task PosixNativeLaunchCreatesProcessGroup() { @@ -374,14 +537,7 @@ params string[] arguments private static ProcessRunOptions CreateNativeHostOptions( params string[] arguments ) { - var managedHost = GetProcessTestHostPath(); - var hostDirectory = System.IO.Path.GetDirectoryName( - managedHost - ) ?? throw new InvalidOperationException( "Unable to locate the process test host directory." ); - var host = System.IO.Path.Combine( - hostDirectory, - "Icod.Processes.ProcessTestHost" - ); + var host = GetNativeProcessTestHostPath(); Assert.True( File.Exists( host ), $"Native process test host was not built at '{host}'." @@ -399,6 +555,17 @@ params string[] arguments return options; } + private static string GetNativeProcessTestHostPath() { + var managedHost = GetProcessTestHostPath(); + var hostDirectory = System.IO.Path.GetDirectoryName( + managedHost + ) ?? throw new InvalidOperationException( "Unable to locate the process test host directory." ); + return System.IO.Path.Combine( + hostDirectory, + "Icod.Processes.ProcessTestHost" + ); + } + private static string GetProcessTestHostPath() { var targetFrameworkDirectory = new DirectoryInfo( AppContext.BaseDirectory From f60abcb27fe737fb1a256f4edba1802c4b2d8aa4 Mon Sep 17 00:00:00 2001 From: uniblab Date: Sun, 30 Aug 2026 18:05:38 -0400 Subject: [PATCH 2/3] 1.2.0 --- src/PosixFileDescriptorMutationScope.cs | 83 ++++++++++++++++++++----- src/ProcessNative.cs | 13 ++++ 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/src/PosixFileDescriptorMutationScope.cs b/src/PosixFileDescriptorMutationScope.cs index 1c40be1..ee142db 100644 --- a/src/PosixFileDescriptorMutationScope.cs +++ b/src/PosixFileDescriptorMutationScope.cs @@ -99,14 +99,27 @@ bool unreadableStandardInput } var minimumBackupDescriptor = descriptors[ ^1 ] + 1; foreach ( var descriptor in descriptors ) { - var backup = ProcessNative.Fcntl( - descriptor, - ProcessNative.DuplicateFileDescriptor, - minimumBackupDescriptor - ); + int backup; + int duplicateError; + if ( OperatingSystem.IsMacOS() ) { + backup = DuplicateDescriptorOutsideSet( + descriptor, + descriptorSet, + out duplicateError + ); + } else { + backup = ProcessNative.Fcntl( + descriptor, + ProcessNative.DuplicateFileDescriptor, + minimumBackupDescriptor + ); + duplicateError = 0 > backup + ? Marshal.GetLastPInvokeError() + : 0 + ; + } if ( 0 > backup ) { - var error = Marshal.GetLastPInvokeError(); - if ( ProcessNative.BadFileDescriptor == error ) { + if ( ProcessNative.BadFileDescriptor == duplicateError ) { this._preserved.Add( new PreservedDescriptor( descriptor, @@ -117,14 +130,21 @@ bool unreadableStandardInput continue; } throw new InvalidOperationException( - $"Unable to preserve file descriptor {descriptor} (errno {error})." + $"Unable to preserve file descriptor {descriptor} (errno {duplicateError})." ); } - if ( 0 > ProcessNative.Fcntl( - backup, - ProcessNative.SetFileDescriptorFlags, - ProcessNative.CloseOnExec - ) ) { + var closeOnExecResult = OperatingSystem.IsMacOS() + ? ProcessNative.Ioctl( + backup, + ProcessNative.DarwinFileIoCloseOnExec + ) + : ProcessNative.Fcntl( + backup, + ProcessNative.SetFileDescriptorFlags, + ProcessNative.CloseOnExec + ) + ; + if ( 0 > closeOnExecResult ) { var error = Marshal.GetLastPInvokeError(); _ = ProcessNative.Close( backup @@ -145,7 +165,42 @@ bool unreadableStandardInput "No descriptor number remains for launch-state preservation." ); } - minimumBackupDescriptor = backup + 1; + minimumBackupDescriptor = Math.Max( + minimumBackupDescriptor, + backup + 1 + ); + } + } + + private static int DuplicateDescriptorOutsideSet( + int descriptor, + HashSet descriptorSet, + out int error + ) { + var reservations = new List(); + try { + while ( true ) { + var duplicate = ProcessNative.Dup( + descriptor + ); + if ( 0 > duplicate ) { + error = Marshal.GetLastPInvokeError(); + return duplicate; + } + if ( !descriptorSet.Contains( duplicate ) ) { + error = 0; + return duplicate; + } + reservations.Add( + duplicate + ); + } + } finally { + foreach ( var reservation in reservations ) { + _ = ProcessNative.Close( + reservation + ); + } } } diff --git a/src/ProcessNative.cs b/src/ProcessNative.cs index 38cd510..593ecaf 100644 --- a/src/ProcessNative.cs +++ b/src/ProcessNative.cs @@ -35,6 +35,8 @@ internal static class ProcessNative { internal const int SetFileDescriptorFlags = 2; /// Gets the POSIX close-on-exec descriptor flag. internal const int CloseOnExec = 1; + /// Gets Darwin's no-argument FIOCLEX request for marking a descriptor close-on-exec. + internal static nuint DarwinFileIoCloseOnExec => 0x20006601u; /// Represents the POSIX union sigval used by sigqueue(3). [StructLayout( LayoutKind.Explicit )] @@ -109,6 +111,17 @@ internal static extern int Fcntl( int argument ); + /// Invokes a no-argument POSIX file-descriptor ioctl request. + [DllImport( + "libc", + EntryPoint = "ioctl", + SetLastError = true + )] + internal static extern int Ioctl( + int descriptor, + nuint request + ); + /// Invokes POSIX kill. [DllImport( "libc", From 9a8f51556035ef4fc29622ee2f74305f685deb77 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 30 Aug 2026 18:14:23 -0400 Subject: [PATCH 3/3] Document 1.1.0 and 1.2.0 improvements --- README.md | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3ca86fa..702cbe9 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,113 @@ originally incubated under `Icod.CommandFramework.Processes`. - POSIX queued signal delivery for individual processes; - POSIX nice-value operations and Windows priority-class substitutions; - POSIX launch-time signal disposition/mask policy; -- atomic POSIX child process-group creation when the native launch path is used; and -- opt-in POSIX current-process replacement with reversible descriptor actions and execvp-compatible executable-text fallback. +- atomic POSIX child process-group creation when the native launch path is used; +- ordered native POSIX file-descriptor duplication and closure at child launch; + and +- opt-in POSIX current-process replacement with reversible descriptor actions + and `execvp`-compatible executable-text fallback. + +## Release highlights + +### 1.2.0 — POSIX current-process replacement + +Version 1.2.0 adds an opt-in process-image replacement path for Unix-like hosts. +Set `ProcessRunOptions.ReplaceCurrentProcess` to request native `execve` behavior +instead of creating and supervising a child process. + +On successful replacement, `RunAsync` does not return: the calling process is +replaced by the requested executable and keeps its process identity. This is +important for Unix-style wrapper commands where PID, job-control, signal, and +standard-descriptor semantics belong directly to the target program rather than +to a long-lived managed parent. + +The replacement path supports: + +- exact argument vectors and an explicit native `argv[0]`; +- exact environment snapshots and executable lookup; +- an optional working directory; +- launch-time POSIX signal disposition and mask policy; +- unreadable standard input for commands such as `nohup`; +- ordered `PosixFileDescriptorDuplications` immediately before `execve`; +- restoration of descriptor and launch state when replacement fails; and +- the traditional `execvp` behavior of retrying executable text through + `/bin/sh` when the initial exec fails with `ENOEXEC`. + +For example, an exec-style wrapper can request replacement without changing the +public process-execution abstraction: + +```csharp +using Icod.Processes; + +var options = new ProcessRunOptions( "program" ) { + ArgumentZero = "program", + Environment = ProcessEnvironment.CreateInheritedBuilder().Build(), + ReplaceCurrentProcess = true, + ResolveExecutable = true, + ReturnLaunchFailureResult = true +}; +options.Arguments.Add( "argument" ); + +ProcessResult result = await ProcessRunner.RunAsync( options ); +// Reached only if replacement did not succeed. +``` + +Current-process replacement is a POSIX capability and is unsupported on +Windows. It cannot be combined with managed standard-stream redirection or +capture, creation of a new child process group, a managed execution timeout, or +a `ProcessStarted` callback. Callers that require those supervisory features +should continue to use normal child-process execution. + +This capability is intended for wrapper implementations such as `env`, `nice`, +`nohup`, and `stdbuf`, where successful Unix execution traditionally replaces +the wrapper process rather than leaving a supervisor behind. + +### 1.1.0 — Native POSIX file-descriptor actions + +Version 1.1.0 added ordered native POSIX file-descriptor duplication to +`ProcessRunOptions`. `PosixFileDescriptorDuplication` describes a `dup2`-style +source-to-destination mapping and can optionally close the source descriptor +after the duplication. + +Actions execute in list order. This permits later actions to refer to descriptor +state established by earlier actions. For example, a wrapper can redirect +standard output to an already-open file descriptor and then make standard error +refer to that same open-file description: + +```csharp +using Icod.Processes; + +var options = new ProcessRunOptions( "program" ) { + ResolveExecutable = true, + ReturnLaunchFailureResult = true +}; + +options.PosixFileDescriptorDuplications.Add( + new PosixFileDescriptorDuplication( + outputFileDescriptor, + 1, + closeSource: true + ) +); +options.PosixFileDescriptorDuplications.Add( + new PosixFileDescriptorDuplication( + 1, + 2 + ) +); + +ProcessResult result = await ProcessRunner.RunAsync( options ); +``` + +Unlike managed stream forwarding, these actions modify the child's native file +descriptors at launch. The child therefore observes the actual descriptor type, +seekability, open-file-description identity, and inheritance semantics rather +than a parent-managed pipe. This is particularly important for Unix wrappers +such as `nohup` and for programs that inspect their own standard descriptors. + +Native descriptor actions are supported on Linux and macOS and are unsupported +on Windows. They cannot be combined with managed standard-stream redirection or +output capture. ## Requirements