Ok so I believe I've gotten to the ideal solution, many thanks to Remus and Usr. I'm leaving my previous solution up as it's fairly viable for small operations.
The biggest problem is when the lineage chain is broken, terminating all child processes becomes quite difficult. Ie A creates B, B creates C but then B ends - A looses any scope over C.
For my testing I modified my TestApp into something rather horrible, a self-spawning nightmare with a self-terminating flipflop. The nasty code for it is at the bottom of this answer, which I suggest anyone only looks at for reference only.
The only answer it seems to govern this nightmare is via Job Objects. I used the class from this answer (credit to Alexander Yezutov -> Matt Howells -> 'Josh') but had to modify it slight to work (therefore I'm posting it's code). I added this class to my project:
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace JobManagement
{
public class Job : IDisposable
{
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
static extern IntPtr CreateJobObject(IntPtr a, string lpName);
[DllImport("kernel32.dll")]
static extern bool SetInformationJobObject(IntPtr hJob, JobObjectInfoType infoType, IntPtr lpJobObjectInfo, UInt32 cbJobObjectInfoLength);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool CloseHandle(IntPtr hObject);
private IntPtr _handle;
private bool _disposed;
public Job()
{
_handle = CreateJobObject(IntPtr.Zero, null);
var info = new JOBOBJECT_BASIC_LIMIT_INFORMATION
{
LimitFlags = 0x2000
};
var extendedInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
BasicLimitInformation = info
};
int length = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION));
IntPtr extendedInfoPtr = Marshal.AllocHGlobal(length);
Marshal.StructureToPtr(extendedInfo, extendedInfoPtr, false);
if (!SetInformationJobObject(_handle, JobObjectInfoType.ExtendedLimitInformation, extendedInfoPtr, (uint)length))
{
throw new Exception(string.Format("Unable to set information. Error: {0}", Marshal.GetLastWin32Error()));
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
private void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing) { }
Close();
_disposed = true;
}
public void Close()
{
CloseHandle(_handle);
_handle = IntPtr.Zero;
}
public bool AddProcess(IntPtr processHandle)
{
return AssignProcessToJobObject(_handle, processHandle);
}
public bool AddProcess(int processId)
{
return AddProcess(Process.GetProcessById(processId).Handle);
}
}
#region Helper classes
[StructLayout(LayoutKind.Sequential)]
struct IO_COUNTERS
{
public UInt64 ReadOperationCount;
public UInt64 WriteOperationCount;
public UInt64 OtherOperationCount;
public UInt64 ReadTransferCount;
public UInt64 WriteTransferCount;
public UInt64 OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
struct JOBOBJECT_BASIC_LIMIT_INFORMATION
{
public Int64 PerProcessUserTimeLimit;
public Int64 PerJobUserTimeLimit;
public UInt32 LimitFlags;
public UIntPtr MinimumWorkingSetSize;
public UIntPtr MaximumWorkingSetSize;
public UInt32 ActiveProcessLimit;
public UIntPtr Affinity;
public UInt32 PriorityClass;
public UInt32 SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)]
public struct SECURITY_ATTRIBUTES
{
public UInt32 nLength;
public IntPtr lpSecurityDescriptor;
public Int32 bInheritHandle;
}
[StructLayout(LayoutKind.Sequential)]
struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public UIntPtr ProcessMemoryLimit;
public UIntPtr JobMemoryLimit;
public UIntPtr PeakProcessMemoryUsed;
public UIntPtr PeakJobMemoryUsed;
}
public enum JobObjectInfoType
{
AssociateCompletionPortInformation = 7,
BasicLimitInformation = 2,
BasicUIRestrictions = 4,
EndOfJobTimeInformation = 6,
ExtendedLimitInformation = 9,
SecurityLimitInformation = 5,
GroupInformation = 11
}
#endregion
}
Changed my main method's content to look like this:
var t = new Thread(delegate()
{
try
{
using (var jobHandler = new Job())
{
var processInfo = new ProcessStartInfo("cmd.exe", "/c " + cmd);
processInfo.CreateNoWindow = true;
processInfo.UseShellExecute = false;
processInfo.RedirectStandardError = true;
processInfo.RedirectStandardOutput = true;
using (Process process = Process.Start(processInfo))
{
DateTime started = process.StartTime;
jobHandler.AddProcess(process.Id); //add the PID to the Job object
process.EnableRaisingEvents = true;
process.OutputDataReceived += (object sender, DataReceivedEventArgs e) => WriteToDebugTextBox(e.Data);
process.BeginOutputReadLine();
process.WaitForExit(_postProcessesTimeOut * 1000);
TimeSpan tpt = (DateTime.Now - started);
if (Math.Abs(tpt.TotalMilliseconds) > (_postProcessesTimeOut * 1000))
{
WriteToDebugTextBox("Timeout reached, terminating all child processes"); //jobHandler.Close() will do this, just log that the timeout was reached
}
}
jobHandler.Close(); //this will terminate all spawned processes
}
}
catch (Exception ex)
{
WriteToDebugTextBox("ERROR:" + ex.Message);
}
WriteToDebugTextBox("Finished Post Process");
});
t.Start();
Feedback through the method looks like this (note: it looses scope part way through but TestApp continues to run and propagate):
13:06:31.055 Executing Threaded Post Process 'test.bat'
13:06:31.214 24976 TestApp started
13:06:31.226 24976 Now going to make a horrible mess by calling myself in 1 second...
13:06:32.213 24976 Creating Child Process cmd 'TestApp.exe'
13:06:32.229 24976 Finished Child-Threaded Process
13:06:32.285 24976 TestApp is going to have a little 'sleep' for 10 seconds
13:06:32.336 24976 Created New Process 26936
13:06:32.454 20344 TestApp started
13:06:32.500 20344 Now going to make a horrible mess by calling myself in 1 second...
13:06:32.512 20344 !! I will self terminate after creating a child process to break the lineage chain
13:06:33.521 20344 Creating Child Process cmd 'TestApp.exe'
13:06:33.540 20344 Finished Child-Threaded Process
13:06:33.599 20344 Created New Process 24124
13:06:33.707 19848 TestApp started
13:06:33.759 19848 Now going to make a horrible mess by calling myself in 1 second...
13:06:34.540 20344 !! Topping myself! PID 20344 Scope lost after here
13:06:41.139 Timeout reached, terminating all child processes
Note the PIDs vary because TestApp isn't being directly called, it's being passed through CMD - I was going for extreme here ;)
Here's TestApp, I strongly suggest this is used for reference only as it will run amok creating new instances of itself (it does have a 'kill' argument for clean up if anyone runs it!).
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace TestApp
{
class Program
{
/// <summary>
/// TestApp.exe [hangtime(int)] [self-terminate(bool)]
/// TestApp.exe kill --terminate all processes called TestApp
/// </summary>
/// <param name="args"></param>
static void Main(string[] args)
{
int hangTime = 5; //5 second default
bool selfTerminate = true;
Process thisProcess = Process.GetCurrentProcess();
if (args.Length > 0)
{
if (args[0] == "kill")
{
KillAllTestApps(thisProcess);
return;
}
hangTime = int.Parse(args[0]);
if (args.Length > 1)
{
selfTerminate = bool.Parse(args[1]);
}
}
Console.WriteLine("{0} TestApp started", thisProcess.Id);
Console.WriteLine("{0} Now going to make a horrible mess by calling myself in 1 second...", thisProcess.Id);
if (selfTerminate)
{
Console.WriteLine("{0} !! I will self terminate after creating a child process to break the lineage chain", thisProcess.Id);
}
Thread.Sleep(1000);
ExecutePostProcess("TestApp.exe", thisProcess, selfTerminate);
if (selfTerminate)
{
Thread.Sleep(1000);
Console.WriteLine("{0} !! Topping myself! PID {0}", thisProcess.Id);
thisProcess.Kill();
}
Console.WriteLine("{0} TestApp is going to have a little 'sleep' for {1} seconds", thisProcess.Id, hangTime);
Thread.Sleep(hangTime * 1000);
Console.WriteLine("{0} Test App has woken up!", thisProcess.Id);
}
public static void ExecutePostProcess(string cmd, Process thisProcess, bool selfTerminate)
{
Console.WriteLine("{0} Creating Child Process cmd '{1}'", thisProcess.Id, cmd);
var t = new Thread(delegate()
{
try
{
var processInfo = new ProcessStartInfo("cmd.exe", "/c " + cmd + " 10 " + (selfTerminate ? "false" : "true" ));
processInfo.CreateNoWindow = true;
processInfo.UseShellExecute = false;
processInfo.RedirectStandardError = true;
processInfo.RedirectStandardOutput = true;
using (Process process = Process.Start(processInfo))
{
Console.WriteLine("{0} Created New Process {1}", thisProcess.Id, process.Id);
process.EnableRaisingEvents = true;
process.OutputDataReceived += (object sender, DataReceivedEventArgs e) => Console.WriteLine(e.Data);
process.BeginOutputReadLine();
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
});
t.Start();
Console.WriteLine("{0} Finished Child-Threaded Process", thisProcess.Id);
}
/// <summary>
/// kill all TestApp processes regardless of parent
/// </summary>
private static void KillAllTestApps(Process thisProcess)
{
Process[] processes = Process.GetProcessesByName("TestApp");
foreach(Process p in processes)
{
if (thisProcess.Id != p.Id)
{
Console.WriteLine("Killing {0}:{1}", p.ProcessName, p.Id);
p.Kill();
}
}
}
}
}