зеркало из https://github.com/microsoft/fsharplu.git
411 строки
21 KiB
Forth
411 строки
21 KiB
Forth
/// System diagnotics, process and logging helpers
|
|
namespace Microsoft.FSharpLu.Diagnostics
|
|
|
|
open System
|
|
open System.Security.Principal
|
|
open Microsoft.FSharpLu.Platform
|
|
|
|
module Elevation =
|
|
/// Determine if the process runs elevated
|
|
let isElevated () =
|
|
use user = WindowsIdentity.GetCurrent()
|
|
let principal = WindowsPrincipal(user)
|
|
principal.IsInRole(WindowsBuiltInRole.Administrator)
|
|
|
|
/// ensure that the process runs with elevated permissions
|
|
let ensureElevated () =
|
|
if not (isElevated ()) then
|
|
invalidOp "Elevated privileges required."
|
|
|
|
/// Process execution helpers
|
|
module Process =
|
|
open System.Diagnostics
|
|
open Microsoft.FSharpLu.Logging
|
|
|
|
/// Process information returned by startProcessAsync
|
|
type ProcessResult =
|
|
{
|
|
ProcessExited : bool
|
|
ExitCode : int
|
|
StandardOutput : string
|
|
StandardError : string
|
|
ExecutionTime : TimeSpan
|
|
ProcessId : int
|
|
}
|
|
|
|
/// Process start flag parameters for startProcessAsync
|
|
[<FlagsAttribute>]
|
|
type ProcessStartFlags =
|
|
| None = 0x0
|
|
| Elevated = 0x1
|
|
| Minimized = 0x2
|
|
| RedirectStandardOutput = 0x4
|
|
| RedirectStandardError = 0x8
|
|
/// Arguments contain sensitive information like passwords that should not be printed out to logs
|
|
| SensitiveArguments = 0x10
|
|
/// Environment variables contain sensitive information like passwords that should not be printed out to logs
|
|
| SensitiveEnvironmentVariables = 0x20
|
|
/// Run command elevated if environment supports elevation, otherwise run as regular user
|
|
| ElevatedIfSupported = 0x40
|
|
|
|
/// Process timeout option
|
|
type ProcessTimeoutAction =
|
|
| AttemptToKillProcessAfterTimeout of TimeSpan
|
|
| KeepTheProcessRunningAfterTimeout of TimeSpan
|
|
| NoTimeout
|
|
|
|
/// Kills the process by process unique identifier - pid.
|
|
let killProcess (pid:uint32) =
|
|
try
|
|
let ps = Process.GetProcessById (int pid)
|
|
if not <| isNull ps then
|
|
ps.Kill ()
|
|
with
|
|
| :? System.ArgumentException ->
|
|
Trace.warning "Could not kill process %d" pid
|
|
|
|
/// Kills the process by process name.
|
|
let killProcessByName (name: string) =
|
|
if name.EndsWith(".exe") then
|
|
Trace.warning "Killing a process by name expects a friendly name without .exe to be used"
|
|
|
|
let ps = System.Diagnostics.Process.GetProcessesByName(name)
|
|
Trace.info "Trying to find process %s, found processes to kill: %A" name ps
|
|
ps |> Array.iter(fun p ->
|
|
try
|
|
p.Kill()
|
|
let processExited = p.WaitForExit(int (TimeSpan.FromSeconds(2.0).TotalMilliseconds))
|
|
if processExited then
|
|
// From MSDN: When standard output has been redirected to asynchronous event handlers,
|
|
// it is possible that output processing will not have completed when p.WaitForExit() returns.
|
|
// To ensure that asynchronous event handling has been completed, call the WaitForExit() overload
|
|
// that takes no parameter after receiving a true from this overload.
|
|
p.WaitForExit() |> ignore
|
|
else
|
|
Trace.error "Process %s, pid: %d was not terminated after attempting to kill it." name p.Id
|
|
with
|
|
| :? System.ComponentModel.Win32Exception as ex ->
|
|
// This is a warning, because it may be expected, e.g. when the process is terminating when killed.
|
|
Trace.warning "Failed to kill %s, pid: %d with exception: %A" name p.Id ex
|
|
| :? System.InvalidOperationException as ex ->
|
|
Trace.info "Did not kill %s, pid: %d because it has already exited. Exception: %A" name p.Id ex
|
|
| :? System.SystemException as ex ->
|
|
// May be thrown by WaitForExit if the process no longer exists.
|
|
Trace.info "Caught exception %A while waiting for the process to exit. Process %s, pid: %d" ex name p.Id
|
|
)
|
|
|
|
/// Create a Process instance based on the specified parameters
|
|
let createProcessInstance command arguments workingDir (flags:ProcessStartFlags) =
|
|
if flags.HasFlag(ProcessStartFlags.Minimized)
|
|
&& (flags.HasFlag(ProcessStartFlags.RedirectStandardOutput)|| flags.HasFlag(ProcessStartFlags.RedirectStandardError)) then
|
|
Trace.failwith "Incompatible switches: running minimized requires starting the process through ShellExecute while RedirectStandardOutput or RedirectStandardError requires starting the process directly."
|
|
|
|
let useShellExecute =
|
|
if flags.HasFlag ProcessStartFlags.Minimized then
|
|
Trace.warning "Important: Shell execute may pop-up the IE security zone window which synchronously blocks the call to Start()! To run minimized shell execute has to be used"
|
|
true
|
|
else
|
|
false
|
|
|
|
new Process(
|
|
StartInfo =
|
|
ProcessStartInfo
|
|
(
|
|
FileName = command,
|
|
WorkingDirectory = workingDir,
|
|
Arguments = arguments,
|
|
CreateNoWindow = false,
|
|
// Important: Shell execute may pop-up the
|
|
// IE security zone window which synchronously blocks the call to Start()!
|
|
// To run minimized shell execute has to be used
|
|
UseShellExecute = useShellExecute,
|
|
RedirectStandardOutput = flags.HasFlag ProcessStartFlags.RedirectStandardOutput,
|
|
RedirectStandardError = flags.HasFlag ProcessStartFlags.RedirectStandardError,
|
|
WindowStyle = (
|
|
if flags.HasFlag ProcessStartFlags.Minimized then
|
|
ProcessWindowStyle.Minimized
|
|
else
|
|
ProcessWindowStyle.Normal
|
|
),
|
|
|
|
Verb = (
|
|
if flags.HasFlag(ProcessStartFlags.Elevated) then
|
|
match Platform.Current with
|
|
| Platform.Linux -> invalidOp "option elevated not supported on Linux"
|
|
| Platform.Windows -> "runas"
|
|
else if flags.HasFlag(ProcessStartFlags.ElevatedIfSupported) then
|
|
match Platform.Current with
|
|
| Platform.Linux -> String.Empty
|
|
| Platform.Windows -> "runas"
|
|
else
|
|
String.Empty)
|
|
),
|
|
EnableRaisingEvents = true)
|
|
|
|
/// Starts a process and asynchronously wait
|
|
/// for it to terminate
|
|
let startProcessAsync command arguments workingDir (flags:ProcessStartFlags) (timeout:ProcessTimeoutAction) (environmentVariables:List<string*string> option) =
|
|
async {
|
|
let maskedArguments = if flags.HasFlag(ProcessStartFlags.SensitiveArguments) then "***MASKED***" else arguments
|
|
let redirectOutput = flags.HasFlag ProcessStartFlags.RedirectStandardOutput
|
|
let redirectErrors = flags.HasFlag ProcessStartFlags.RedirectStandardError
|
|
use instance = createProcessInstance command arguments workingDir flags
|
|
|
|
environmentVariables
|
|
|> Option.iter
|
|
(fun d -> // UseShellExecute must be false if environment variables are set
|
|
instance.StartInfo.UseShellExecute <- false
|
|
let sensitiveVariables = flags.HasFlag ProcessStartFlags.SensitiveEnvironmentVariables
|
|
d |> List.iter(fun (k,v) ->
|
|
if instance.StartInfo.EnvironmentVariables.ContainsKey(k) then
|
|
if sensitiveVariables then
|
|
Trace.info "Remove environment variable '%s' with sensitive content" k
|
|
else
|
|
Trace.info "Remove environment variable '%s' with value <%s>" k instance.StartInfo.EnvironmentVariables.[k]
|
|
|
|
instance.StartInfo.EnvironmentVariables.Remove(k)
|
|
|
|
instance.StartInfo.EnvironmentVariables.Add(k,v)
|
|
if sensitiveVariables then
|
|
Trace.info "Set environment variable '%s' with sensitive content" k
|
|
else
|
|
Trace.info "Set environment variable '%s' to <%s>" k v))
|
|
|
|
Trace.info "Launching '%s %s'" command maskedArguments
|
|
let timer = System.Diagnostics.Stopwatch()
|
|
|
|
timer.Start()
|
|
use instanceExit = new System.Threading.AutoResetEvent(false)
|
|
|
|
// Note: it's important to register this event __before__ calling instance.Start()
|
|
// to avoid a deadlock if the process terminates too quickly...
|
|
instance.Exited.Add
|
|
(fun _ ->
|
|
timer.Stop()
|
|
// ... but this handler still gets called if the process instance gets killed
|
|
// (e.g. using .Kill() function) before the underlying OS process gets actually
|
|
/// started with .Start()!
|
|
/// This then causes below evaluation of property `.ExitCode` to throw with:
|
|
// `System.InvalidOperationException: No process is associated with this object`
|
|
// we thus wrap the handler within a try .. catch block.
|
|
try
|
|
Trace.info "Process execution terminated in %O with exit code 0x%X: '%O %O'" timer.Elapsed (int32 instance.ExitCode) command maskedArguments
|
|
with :? System.InvalidOperationException ->
|
|
Trace.info "Process execution terminated abruptly in %O with no exit code: '%O %O'" timer.Elapsed command maskedArguments
|
|
if not instanceExit.SafeWaitHandle.IsClosed then
|
|
instanceExit.Set() |> ignore)
|
|
|
|
// IMPORTANT NOTE:
|
|
// It is tempting here to use
|
|
// Async.AwaitEvent(instance.Exited)
|
|
// to detect when the process ends, instead of relying on
|
|
// an extra System.Threading.AutoResetEvent.
|
|
//
|
|
// However this can hang when stars don't align...
|
|
// (See unit test `NoHangInStartProcessLogic` for details.)
|
|
// Also, awaiting with process.Wait also leads to hang when
|
|
// attempting to capture stdout/stderr.
|
|
let waitAsync =
|
|
match timeout with
|
|
| NoTimeout ->
|
|
Async.AwaitWaitHandle(instanceExit)
|
|
| AttemptToKillProcessAfterTimeout t
|
|
| KeepTheProcessRunningAfterTimeout t ->
|
|
Async.AwaitWaitHandle(instanceExit, int <| t.TotalMilliseconds)
|
|
|
|
// Standard output must be read prior to waiting on the instance to exit.
|
|
// Otherwise, a deadlock is created when the child process has filled its output
|
|
// buffer and waits for the parent to consume it, and the parent waits for the
|
|
// child process to exit first.
|
|
// Reference: https://stackoverflow.com/questions/139593/processstartinfo-hanging-on-waitforexit-why?lq=1
|
|
let standardOutput = System.Text.StringBuilder()
|
|
let standardError = System.Text.StringBuilder()
|
|
use noMoreOutput = new System.Threading.AutoResetEvent(false)
|
|
use noMoreError = new System.Threading.AutoResetEvent(false)
|
|
let appendHandler
|
|
(endOfStreamEvent:System.Threading.AutoResetEvent)
|
|
(aggregator:System.Text.StringBuilder)
|
|
(args:DataReceivedEventArgs) =
|
|
if isNull args.Data then
|
|
if not endOfStreamEvent.SafeWaitHandle.IsClosed then
|
|
endOfStreamEvent.Set() |> ignore
|
|
else
|
|
aggregator.AppendLine(args.Data) |> ignore
|
|
|
|
if redirectOutput then
|
|
instance.OutputDataReceived.Add(appendHandler noMoreOutput standardOutput)
|
|
|
|
if redirectErrors then
|
|
instance.ErrorDataReceived.Add(appendHandler noMoreError standardError)
|
|
|
|
if not (instance.Start()) then
|
|
let message = sprintf "Could not start command: '%s' with parameters '%s'" command maskedArguments
|
|
return raise <| System.InvalidOperationException(message)
|
|
else
|
|
if redirectOutput then
|
|
instance.BeginOutputReadLine()
|
|
if redirectErrors then
|
|
instance.BeginErrorReadLine()
|
|
|
|
let! exitedBeforeTimeout = waitAsync
|
|
|
|
let exitCode =
|
|
if exitedBeforeTimeout then
|
|
Trace.info "(%d) %s %s exited with code: %d" instance.Id command maskedArguments instance.ExitCode
|
|
instance.ExitCode
|
|
else
|
|
match timeout with
|
|
| NoTimeout ->
|
|
failwith "Impossible case: waitAsync timed out with an infinite timeout value!"
|
|
| AttemptToKillProcessAfterTimeout t
|
|
| KeepTheProcessRunningAfterTimeout t ->
|
|
Trace.info "Process (%d) [%s %s] did not exit within allocated time out of %f seconds." instance.Id command maskedArguments t.TotalSeconds
|
|
// Note: calling instance.ExitCode would throw:
|
|
// System.InvalidOperationException: Process must exit before requested information can be determined.
|
|
-1
|
|
|
|
if exitedBeforeTimeout then
|
|
// Read the stdout and stderr
|
|
if redirectOutput then
|
|
let! _ = Async.AwaitWaitHandle noMoreOutput
|
|
Trace.verbose "Standard output captured (%d) [%s %s]" instance.Id command maskedArguments
|
|
|
|
if redirectErrors then
|
|
let! _ = Async.AwaitWaitHandle noMoreError
|
|
Trace.verbose "Standard error captured (%d) [%s %s]" instance.Id command maskedArguments
|
|
else
|
|
// We should not read stdoud/stderr since the time out period is already exceeded,
|
|
// and reading the standard outputerror would indirectly wait for the process to terminate!
|
|
match timeout with
|
|
| KeepTheProcessRunningAfterTimeout _
|
|
| NoTimeout -> ()
|
|
| AttemptToKillProcessAfterTimeout t ->
|
|
Trace.info "Killing timed-out process (%d) [%s %s]" instance.Id command maskedArguments
|
|
try
|
|
instance.Kill()
|
|
Trace.info "Process killed (%d) [%s %s]" instance.Id command maskedArguments
|
|
with _ ->
|
|
Trace.warning "Failed to kill process (%d) [%s %s]" instance.Id command maskedArguments
|
|
|
|
return
|
|
{
|
|
ProcessResult.ProcessExited = exitedBeforeTimeout
|
|
ProcessId = instance.Id
|
|
ExitCode = exitCode
|
|
ExecutionTime = timer.Elapsed
|
|
StandardOutput = standardOutput.ToString()
|
|
StandardError = standardError.ToString()
|
|
}
|
|
}
|
|
|
|
// Start a process and returns an asynchronous workflow that waits
|
|
// for it to terminate and return the process exit code
|
|
let startProcessAsyncAndWait command arguments workingDir flags =
|
|
async {
|
|
let! processResult = startProcessAsync command arguments workingDir flags NoTimeout None
|
|
return processResult.ExitCode
|
|
}
|
|
|
|
/// Start a process and asynchronously wait for it to terminate
|
|
/// redirect stdout and stderr to Trace.info and Trace.error
|
|
let startProcessWithStdTracingAsync command arguments workingDir (flags:ProcessStartFlags) =
|
|
async {
|
|
use resetEvent = new System.Threading.ManualResetEvent false
|
|
let maskedArguments = if flags.HasFlag(ProcessStartFlags.SensitiveArguments) then "***MASKED***" else arguments
|
|
|
|
use p = createProcessInstance
|
|
command
|
|
arguments
|
|
workingDir
|
|
(ProcessStartFlags.RedirectStandardError
|
|
||| ProcessStartFlags.RedirectStandardOutput
|
|
||| flags)
|
|
|
|
p.ErrorDataReceived
|
|
|> Event.add (fun dataReceived ->
|
|
if isNull dataReceived.Data then
|
|
() // There is no more standard error
|
|
else
|
|
Trace.error "%s" dataReceived.Data)
|
|
|
|
p.OutputDataReceived
|
|
|> Event.add(fun dataReceived ->
|
|
if isNull dataReceived.Data then
|
|
// There is no more standard output
|
|
()
|
|
else
|
|
Trace.info "%s" dataReceived.Data)
|
|
|
|
p.Exited.Add (fun _ -> resetEvent.Set() |> ignore)
|
|
if not (p.Start()) then
|
|
let message = sprintf "Could not start command: '%s' with parameters '%s'" command maskedArguments
|
|
raise <| System.InvalidOperationException(message)
|
|
|
|
p.BeginOutputReadLine()
|
|
p.BeginErrorReadLine()
|
|
let! _ = resetEvent |> Async.AwaitWaitHandle
|
|
return p.ExitCode
|
|
}
|
|
|
|
/// Start an external command, Powershell script or batch file and asynchronously wait for it to terminate.
|
|
let startScriptAsyncAux (script:string) additionalParameters workingDir flags runAsNative timeout environmentVariables =
|
|
let batchScriptCommand scriptFileName =
|
|
"cmd.exe", sprintf "/c %s %s" scriptFileName additionalParameters
|
|
|
|
let powershellScriptCommand scriptFileName =
|
|
let systemDir =
|
|
if runAsNative && Environment.Is64BitOperatingSystem && not Environment.Is64BitProcess then
|
|
System.Environment.GetEnvironmentVariable("windir") + "\\sysnative"
|
|
else
|
|
System.Environment.GetEnvironmentVariable("SystemRoot") + "\\system32"
|
|
let powershell = sprintf @"%s\WindowsPowerShell\v1.0\powershell.exe" systemDir
|
|
powershell, sprintf "-nologo -NoProfile -executionpolicy bypass -Command \"& { %s %s } ; exit $LASTEXITCODE\"" scriptFileName additionalParameters
|
|
|
|
let bashScriptCommand scriptFileName =
|
|
"/bin/bash", (sprintf "-c \"chmod +x %s; %s %s\"" scriptFileName scriptFileName additionalParameters)
|
|
|
|
let command, parameters =
|
|
if script.EndsWith(".cmd", System.StringComparison.InvariantCultureIgnoreCase) then
|
|
batchScriptCommand script
|
|
else if script.EndsWith(".ps1", System.StringComparison.InvariantCultureIgnoreCase) then
|
|
powershellScriptCommand script
|
|
else if script.EndsWith(".exe", System.StringComparison.InvariantCultureIgnoreCase) then
|
|
script, ""
|
|
else if script.EndsWith(".sh", System.StringComparison.InvariantCulture) then
|
|
bashScriptCommand script
|
|
else
|
|
invalidArg "script" "Unsupported script file"
|
|
|
|
startProcessAsync command parameters workingDir flags timeout environmentVariables
|
|
|
|
/// Start an external command, Powershell script or batch file, asynchronously wait for it to terminate.
|
|
/// Return the process exit code.
|
|
let startScriptAsync script additionalParameters workingDir flags runAsNative =
|
|
async {
|
|
let! processResult = startScriptAsyncAux script additionalParameters workingDir flags runAsNative NoTimeout None
|
|
return processResult.ExitCode
|
|
}
|
|
|
|
|
|
module Assembly =
|
|
open System.Reflection
|
|
open System.Runtime.CompilerServices
|
|
|
|
/// Get path of the currently executing assembly
|
|
[<MethodImpl(MethodImplOptions.NoInlining)>]
|
|
let getCurrentAssemblyPath() =
|
|
let callingAssembly = Assembly.GetCallingAssembly()
|
|
callingAssembly.Location
|
|
|
|
|
|
module Extensions =
|
|
/// Extension to use Stopwatch to measure performance of async computations
|
|
type System.Diagnostics.Stopwatch with
|
|
member x.Measure(task:Async<'t>) =
|
|
async {
|
|
x.Restart()
|
|
let! r = task
|
|
x.Stop()
|
|
return r
|
|
}
|