-
Notifications
You must be signed in to change notification settings - Fork 18
/
Invoke-PackageCommand.ps1
433 lines (383 loc) Β· 22.8 KB
/
Invoke-PackageCommand.ps1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
ο»Ώfunction Invoke-PackageCommand {
<#
.SYNOPSIS
Tries to run a command and returns an object containing an error
code and optionally information about the process that was run.
.PARAMETER Path
.PARAMETER Command
File path to the excutable and its arguments in one string.
The string can contain environment variables as well.
.PARAMETER Executable
File path to the executable to run. The path to the executable is currently not
resolved to an absolute path but run as-is. Variables are not expanded either.
Because of this the caller should already pass an absolute, verbatim path toArguments this parameter.
.PARAMETER Arguments
The optional command line arguments to run the executable with, as a single string.
#>
[CmdletBinding()]
[OutputType('ExternalProcessResult')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseDeclaredVarsMoreThanAssignments',
'ProcessKilledTimeout',
Justification = 'https://github.com/PowerShell/PSScriptAnalyzer/issues/1163'
)]
Param (
[ValidateNotNullOrEmpty()]
[string]$Path,
[ValidateNotNullOrEmpty()]
[Parameter( Mandatory = $true, ParameterSetName = 'CommandString' )]
[string]$Command,
[Parameter( Mandatory = $true, ParameterSetName = 'ExeAndArgs' )]
[string]$Executable,
[Parameter( ParameterSetName = 'ExeAndArgs' )]
[string]$Arguments = '',
[switch]$FallbackToShellExecute,
[TimeSpan]$RuntimeLimit = [TimeSpan]::Zero
)
# Remove any trailing backslashes from the Path.
# This isn't necessary, because Split-ExecutableAndArguments can handle and trims
# extra backslashes, but this will make the path look more sane in errors and warnings.
$Path = $Path.TrimEnd('\')
if ($PSCmdlet.ParameterSetName -eq 'CommandString') {
# Lenovo sometimes forgets to put a directory separator betweeen %PACKAGEPATH% and the executable so make sure it's there
# If we end up with two backslashes, Split-ExecutableAndArguments removes the duplicate from the executable path, but
# we could still end up with a double-backslash after %PACKAGEPATH% somewhere in the arguments for now.
[string]$ExpandedCommandString = Resolve-CmdVariable -String $Command -ExtraVariables @{'PACKAGEPATH' = "${Path}\"; 'WINDOWS' = $env:SystemRoot}
$Executable, $Arguments = Split-ExecutableAndArguments -Command $ExpandedCommandString -WorkingDirectory $Path
# Split-ExecutableAndArguments returns NULL if no executable could be found
if (-not $Executable) {
Write-Warning "The command or file '$Command' could not be found from '$Path' and was not run"
return [ExternalProcessResult]::new(
[ExternalProcessError]::FILE_NOT_FOUND,
$null
)
}
$Arguments = Remove-CmdEscapeCharacter -String $Arguments
}
Write-Debug "Starting external process:`r`n File: ${Executable}`r`n Arguments: ${Arguments}`r`n WorkingDirectory: ${Path}"
$Runspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateOutOfProcessRunspace($null)
$Runspace.Open()
# Get the PID and handle of our out-of-process runspace ...
$Powershell = [PowerShell]::Create().AddScript{ $PID }
$Powershell.Runspace = $Runspace
$RunspacePID = $Powershell.Invoke() | Select-Object -First 1
$hRunspaceProcess = (Get-Process -Id $RunspacePID).Handle
# ... so we can add the runspace process to a job object.
# Job objects are the only reliable way in Windows to track
# a process tree (process and ALL descendant processes)
# which we want to know in case we have to debug and kill
# the process(es) due to hitting the configured RuntimeLimit
$hJob = [LSUClient.JobAPI]::CreateJobObject([System.IntPtr]::Zero, $null)
[bool]$aptjoSuccess = [LSUClient.JobAPI]::AssignProcessToJobObject($hJob, $hRunspaceProcess)
Write-Debug "Added runspace process $RunspacePID to job: $aptjoSuccess"
$Powershell = [PowerShell]::Create().AddScript{
[CmdletBinding()]
Param (
[ValidateNotNullOrEmpty()]
[string]$WorkingDirectory,
[ValidateNotNullOrEmpty()]
[Parameter( Mandatory = $true )]
[string]$Executable,
[string]$Arguments,
[switch]$FallbackToShellExecute
)
Set-StrictMode -Version 3.0
# This value is used to communicate problems and errors that can be handled and or remedied/retried
# internally to the calling function. It stays 0 when no known errors occurred.
$HandledError = 0
$ProcessStarted = $false
[string[]]$StdOutLines = @()
[string[]]$StdErrLines = @()
$process = [System.Diagnostics.Process]::new()
# WindowStyle used to be set to 'Hidden', but it turns out that does nothing unless used with ShellExecute.
# It had just appeared to work because most processes run properly hidden and silent on their own, but the
# 'Hidden' setting did prevent navigating the Installers of non-unattended packages if they were invoked with
# UseShellExecute due to one of the fallback cases below.
# To keep behavior consistent whether using ShellExeute or not, WindowStyle is now always set to 'Normal'.
# https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.processwindowstyle?view=netframework-4.6.1
$process.StartInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Normal
$process.StartInfo.UseShellExecute = $false
$process.StartInfo.WorkingDirectory = $WorkingDirectory
$process.StartInfo.FileName = $Executable
$process.StartInfo.Arguments = $Arguments
$process.StartInfo.RedirectStandardInput = $true
$process.StartInfo.RedirectStandardOutput = $true
$process.StartInfo.RedirectStandardError = $true
if ($FallbackToShellExecute) {
$process.StartInfo.UseShellExecute = $true
$process.StartInfo.RedirectStandardInput = $false
$process.StartInfo.RedirectStandardOutput = $false
$process.StartInfo.RedirectStandardError = $false
}
try {
if (-not $process.Start()) {
$HandledError = 1
} else {
$ProcessStarted = $true
}
}
catch {
# In case we get ERROR_ELEVATION_REQUIRED (740) retry with ShellExecute to elevate with UAC
if ($null -ne $_.Exception.InnerException -and $_.Exception.InnerException.NativeErrorCode -eq 740) {
$HandledError = 740
# In case we get ERROR_BAD_EXE_FORMAT (193) retry with ShellExecute to open files like MSI
} elseif ($null -ne $_.Exception.InnerException -and $_.Exception.InnerException.NativeErrorCode -eq 193) {
$HandledError = 193
# In case we get ERROR_ACCESS_DENIED (5) e.g. when the file could not be accessed by the running user
} elseif ($null -ne $_.Exception.InnerException -and $_.Exception.InnerException.NativeErrorCode -eq 5) {
$HandledError = 5
} else {
Write-Error $_
$HandledError = 2 # Any other Process.Start exception
}
}
if ($ProcessStarted) {
if (-not $FallbackToShellExecute) {
# When redirecting StandardOutput or StandardError you have to start reading the streams asynchronously, or else it can cause
# programs that output a lot (like package u3aud03w_w10 - Conexant USB Audio) to fill a stream and deadlock/hang indefinitely.
# See issue #25 and https://stackoverflow.com/questions/11531068/powershell-capturing-standard-out-and-error-with-process-object
$StdOutAsync = $process.StandardOutput.ReadToEndAsync()
$StdErrAsync = $process.StandardError.ReadToEndAsync()
# https://github.com/jantari/LSUClient/issues/103
$process.StandardInput.Close()
}
$process.WaitForExit()
if (-not $FallbackToShellExecute) {
$StdOutInOneString = $StdOutAsync.GetAwaiter().GetResult()
$StdErrInOneString = $StdErrAsync.GetAwaiter().GetResult()
[string[]]$StdOutLines = $StdOutInOneString.Split(
[string[]]("`r`n", "`r", "`n"),
[StringSplitOptions]::None
)
[string[]]$StdErrLines = $StdErrInOneString.Split(
[string[]]("`r`n", "`r", "`n"),
[StringSplitOptions]::None
)
}
}
return [PSCustomObject]@{
'StandardOutput' = $StdOutLines
'StandardError' = $StdErrLines
'ExitCode' = $process.ExitCode
'Runtime' = $process.ExitTime - $process.StartTime
'HandledError' = $HandledError
}
}
[void]$Powershell.AddParameters(@{
'WorkingDirectory' = $Path
'Executable' = $Executable
'Arguments' = $Arguments
'FallbackToShellExecute' = $FallbackToShellExecute
})
$Powershell.Runspace = $Runspace
$RunspaceStandardInput = [System.Management.Automation.PSDataCollection[PSObject]]::new()
$RunspaceStandardInput.Complete()
$RunspaceStandardOut = [System.Management.Automation.PSDataCollection[PSObject]]::new()
$RunspaceTimer = [System.Diagnostics.Stopwatch]::new()
$RunspaceTimer.Start()
$PSAsyncRunspace = $Powershell.BeginInvoke($RunspaceStandardInput, $RunspaceStandardOut)
[bool]$ProcessKilledTimeout = $false
[TimeSpan]$LastPrinted = [TimeSpan]::FromMinutes(4)
while ($PSAsyncRunspace.IsCompleted -eq $false) {
# To either print the warning about long-running processes or to kill them
# we have to gather all process IDs from the job object we created first
if (($RunspaceTimer.Elapsed - $LastPrinted -ge [TimeSpan]::FromMinutes(1)) -or
($RuntimeLimit -gt [TimeSpan]::Zero -and $RunspaceTimer.Elapsed -gt $RuntimeLimit)) {
[int]$ListPtrSize = [System.Runtime.InteropServices.Marshal]::SizeOf([LSUClient.JobAPI+JOBOBJECT_BASIC_PROCESS_ID_LIST]::new());
[System.IntPtr]$JobListPtr = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($ListPtrSize)
# QueryInformationJobObject does not write any data out to lpJobObjectInformation (no NumberOfAssignedProcesses) under WOW (PowerShell x86) if it fails:
# https://social.msdn.microsoft.com/Forums/office/en-US/41a7b8c9-6b5e-4c91-b92d-31310522d0cd/wow64-issue-with-queryinformationjobobject-and-jobobjectbasicprocessidlist-including-windows-10?forum=windowssdk
# This means we just have to continually increase the buffer until it's large enough for QueryInformationJobObject to succeed.
[int]$GuessNumberOfAssignedProcesses = 0
# Retry ERROR_MORE_DATA in a loop because it *could* run into a race condition where a new process is spawned
# exactly in between allocating the memory we think we need and the next call to QueryInformationJobObject
do {
[System.UInt32]$qijoReturnLength = 0
[bool]$qijoSuccess = [LSUClient.JobAPI]::QueryInformationJobObject($hJob, 3, $JobListPtr, $ListPtrSize, [ref] $qijoReturnLength)
$Win32Error = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
$JobList = [System.Runtime.InteropServices.Marshal]::PtrToStructure($JobListPtr, [Type][LSUClient.JobAPI+JOBOBJECT_BASIC_PROCESS_ID_LIST])
Write-Debug "QueryInformationJobObject: returned $qijoSuccess, last error: $Win32Error, bytes written: $qijoReturnLength"
if (-not $qijoSuccess -and $Win32Error -eq 234) {
if ($qijoReturnLength -eq 0) {
# Because AllocHGlobal doesn't zero the memory it allocates, the struct will be filled with random data
# if QueryInformationJobObject did not overwrite it so we cannot use NumberOfAssignedProcesses and have to guess
$GuessNumberOfAssignedProcesses += 2
[int]$ListPtrSize = [System.Runtime.InteropServices.Marshal]::SizeOf($JobList) + $GuessNumberOfAssignedProcesses * [System.IntPtr]::Size
} else {
[int]$ListPtrSize = [System.Runtime.InteropServices.Marshal]::SizeOf($JobList) + ($JobList.NumberOfAssignedProcesses - 1) * [System.IntPtr]::Size
}
[System.IntPtr]$JobListPtr = [System.Runtime.InteropServices.Marshal]::ReAllocHGlobal($JobListPtr, $ListPtrSize)
$RetryMoreData = $true
} else {
$RetryMoreData = $false
}
} while ($RetryMoreData)
[System.IntPtr[]]$ProcessIdList = [System.IntPtr[]]::new($JobList.NumberOfProcessIdsInList)
# It's possible the processes and runspace have exited by this point
if ($JobList.NumberOfProcessIdsInList -gt 0) {
# Get the first process ID directly from the marshaled struct
$ProcessIdList[0] = $JobList.ProcessIdList
$PIDListPointer = [System.IntPtr]::Add($JobListPtr, [System.Runtime.InteropServices.Marshal]::SizeOf([LSUClient.JobAPI+JOBOBJECT_BASIC_PROCESS_ID_LIST]::new()))
# Copy the others (variable length) from unmanaged memory manually
[System.Runtime.InteropServices.Marshal]::Copy($PIDListPointer, $ProcessIdList, 1, $JobList.NumberOfProcessIdsInList - 1)
}
[System.Runtime.InteropServices.Marshal]::FreeHGlobal($JobListPtr)
# Filter out our PowerShell runspace process
$ProcessIdList = $ProcessIdList -ne $RunspacePID
Write-Debug "Process IDs in job (without runspace): $ProcessIdList"
# Print message once every minute after an initial 5 minutes of silence
if ($RunspaceTimer.Elapsed - $LastPrinted -ge [TimeSpan]::FromMinutes(1)) {
Write-Debug "(Current session ID: $( [System.Diagnostics.Process]::GetCurrentProcess().SessionId ), Environment.UserInteractive: $( [System.Environment]::UserInteractive ))"
Write-Warning "Process '$Executable' has been running for $($RunspaceTimer.Elapsed)"
# Get-Process -Id errors when passed an empty array so test it first
if ($ProcessIdList) {
# Getting and piping all processes at once with ErrorAction Ignore silently skips over any process that has already exited
Get-Process -Id $ProcessIdList -ErrorAction Ignore | ForEach-Object {
Write-Warning "$($_.Id): '$($_.ProcessName)' started at $($_.StartTime.TimeOfDay)"
$ProcessDiagnostics = Debug-LongRunningProcess -Process $_
if ($ProcessDiagnostics.InteractableWindows) {
Write-Warning "Process has windows open, this can help troubleshoot if it is stuck:"
foreach ($OpenWindow in $ProcessDiagnostics.InteractableWindows) {
Write-Warning "- Title: -------------------------------------------"
Write-Warning $OpenWindow.WindowTitle
Write-Warning "- Content: -----------------------------------------"
$OpenWindow.WindowText | Write-Warning
Write-Warning "----------------------------------------------------"
}
}
}
}
$LastPrinted = $RunspaceTimer.Elapsed
}
# Stop processes after exceeding runtime limit
if ($RuntimeLimit -gt [TimeSpan]::Zero -and $RunspaceTimer.Elapsed -gt $RuntimeLimit) {
Write-Warning "Process has exceeded the configured runtime limit of $RunTimeLimit"
if ($ProcessIdList) {
Get-Process -Id $ProcessIdList -ErrorAction Ignore | ForEach-Object {
# It's possible for a process (object) to linger and be "get-able"
# for a short while after it has already exited. Kill() won't throw
# on these processes, but they didn't technically get "killed" by us
if (-not $_.HasExited) {
Write-Warning "Killing process $($_.Id) '$($_.ProcessName)' due to exceeding time limit ..."
try {
$_.Kill()
# Only set ProcessKilledTimeout if Kill() ran and succeeded
$ProcessKilledTimeout = $true
}
catch [InvalidOperationException] { <# Process has exited in the meantime, which is fine #> }
}
}
}
}
}
Start-Sleep -Milliseconds 200
}
$RunspaceTimer.Stop()
# Print any unhandled / unexpected errors as warnings
if ($PowerShell.Streams.Error.Count -gt 0) {
foreach ($ErrorRecord in $PowerShell.Streams.Error.ReadAll()) {
Write-Warning $ErrorRecord
}
}
$PowerShell.Runspace.Dispose()
$PowerShell.Dispose()
if (-not [LSUClient.JobAPI]::CloseHandle($hJob)) {
Write-Debug "CloseHandle failed for hJob handle"
}
# Test for NULL before indexing into array. RunspaceStandardOut can be null
# when the runspace aborted abormally, for example due to an exception.
if ($null -ne $RunspaceStandardOut -and $RunspaceStandardOut.Count -gt 0) {
switch ($RunspaceStandardOut[-1].HandledError) {
# Success case
0 {
$NonEmptyPredicate = [Predicate[string]] { -not [string]::IsNullOrWhiteSpace($args[0]) }
$StdOutFirstNonEmpty = [array]::FindIndex([string[]]$RunspaceStandardOut[-1].StandardOutput, $NonEmptyPredicate)
if ($StdOutFirstNonEmpty -ne -1) {
$StdOutLastNonEmpty = [array]::FindLastIndex([string[]]$RunspaceStandardOut[-1].StandardOutput, $NonEmptyPredicate)
$StdOutTrimmed = $RunspaceStandardOut[-1].StandardOutput[$StdOutFirstNonEmpty..$StdOutLastNonEmpty]
} else {
$StdOutTrimmed = @()
}
$StdErrFirstNonEmpty = [array]::FindIndex([string[]]$RunspaceStandardOut[-1].StandardError, $NonEmptyPredicate)
if ($StdErrFirstNonEmpty -ne -1) {
$StdErrLastNonEmpty = [array]::FindLastIndex([string[]]$RunspaceStandardOut[-1].StandardError, $NonEmptyPredicate)
$StdErrTrimmed = $RunspaceStandardOut[-1].StandardError[$StdErrFirstNonEmpty..$StdErrLastNonEmpty]
} else {
$StdErrTrimmed = @()
}
$ReturnErr = if ($ProcessKilledTimeout) {
[ExternalProcessError]::PROCESS_KILLED_TIMELIMIT
} else {
[ExternalProcessError]::NONE
}
return [ExternalProcessResult]::new(
$ReturnErr,
[ProcessReturnInformation]@{
'FilePath' = $Executable
'Arguments' = $Arguments
'WorkingDirectory' = $Path
'StandardOutput' = $StdOutTrimmed
'StandardError' = $StdErrTrimmed
'ExitCode' = $RunspaceStandardOut[-1].ExitCode
'Runtime' = $RunspaceStandardOut[-1].Runtime
}
)
}
# Error cases that are handled explicitly inside the runspace
1 {
Write-Warning "No new process was created or a handle to it could not be obtained."
Write-Warning "Executable was: '${Executable}' - this should *probably* not have happened"
return [ExternalProcessResult]::new(
[ExternalProcessError]::PROCESS_NONE_CREATED,
$null
)
}
2 {
return [ExternalProcessResult]::new(
[ExternalProcessError]::UNKNOWN,
$null
)
}
5 {
return [ExternalProcessResult]::new(
[ExternalProcessError]::ACCESS_DENIED,
$null
)
}
740 {
if (-not $FallbackToShellExecute) {
Write-Warning "This process requires elevated privileges - falling back to ShellExecute, consider running PowerShell as Administrator"
Write-Warning "Process output cannot be captured when running with ShellExecute!"
return (Invoke-PackageCommand -Path:$Path -Executable:$Executable -Arguments:$Arguments -FallbackToShellExecute -RuntimeLimit $RuntimeLimit)
} else {
return [ExternalProcessResult]::new(
[ExternalProcessError]::PROCESS_REQUIRES_ELEVATION,
$null
)
}
}
193 {
if (-not $FallbackToShellExecute) {
Write-Warning "The file to be run is not an executable - falling back to ShellExecute"
return (Invoke-PackageCommand -Path:$Path -Executable:$Executable -Arguments:$Arguments -FallbackToShellExecute -RuntimeLimit $RuntimeLimit)
} else {
return [ExternalProcessResult]::new(
[ExternalProcessError]::FILE_NOT_EXECUTABLE,
$null
)
}
}
}
} else {
Write-Warning "The external process runspace did not run to completion because an unexpected error occurred."
return [ExternalProcessResult]::new(
[ExternalProcessError]::RUNSPACE_DIED_UNEXPECTEDLY,
$null
)
}
Write-Warning "An unexpected error occurred when trying to run the extenral process."
return [ExternalProcessResult]::new(
[ExternalProcessError]::UNKNOWN,
$null
)
}