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..702cbe9 100644
--- a/README.md
+++ b/README.md
@@ -25,12 +25,118 @@ 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;
+- 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
-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 +145,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 +186,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 +205,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..ee142db
--- /dev/null
+++ b/src/PosixFileDescriptorMutationScope.cs
@@ -0,0 +1,270 @@
+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 ) {
+ 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 ) {
+ if ( ProcessNative.BadFileDescriptor == duplicateError ) {
+ this._preserved.Add(
+ new PreservedDescriptor(
+ descriptor,
+ -1,
+ false
+ )
+ );
+ continue;
+ }
+ throw new InvalidOperationException(
+ $"Unable to preserve file descriptor {descriptor} (errno {duplicateError})."
+ );
+ }
+ 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
+ );
+ 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 = 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
+ );
+ }
+ }
+ }
+
+ 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..593ecaf 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,14 @@ 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;
+ /// 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 )]
@@ -87,6 +99,29 @@ 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 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",
@@ -291,6 +326,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