Can't get all excel processes to stop when closing through Powershell
Asked Answered
H

2

10

With this code, I am opening excel(with visible = false so the user cannot see it), writing to a workbook, and then either opening excel(making it visible) after the script ends or closing it completely without saving. When I save excel, leave it open, end the script, and then manually close excel later, there are no background processes in the task manager. However, when I close excel with the script, it remains in the task manager.

Here is how I start excel:

    $script:excel = new-object -ComObject excel.application # create excel object
    $excel.visible = $false # hide excel window
    $script:workbook = $excel.Workbooks.Add() # add excel file
    $script:ws1 = $workbook.Worksheets.Item(1) # create new sheet

Here is how I close it:

        [gc]::Collect()
        [gc]::WaitForPendingFinalizers()
        if ($script:closeOnX) {
            #only do this if not keeping excel open
            Write-Host "Closing Excel"
            $excel.Quit()
        }
        [System.Runtime.InteropServices.Marshal]::ReleaseComObject($excel)

closeOnX is just a flag so it only actually closes the excel app on certain occasions. The rest is executed each time the script ends.

When I end the script and close excel at the same time, I want only the current excel process to close (which is why I don't want to stop-process) and not close other workbooks that the user may be working on.

When I end the script, save and open the excel, I want all the processes to be gone when the user manually closes excel. (This is working)

Hemihydrate answered 29/3, 2019 at 18:4 Comment(0)
E
15

tl;dr

Use the following idiom to ensure that all references to COM objects are released, which, in combination with calling $excel.Quit(), ensures that the Excel process (eventually) terminates:

& {  # Create a temporary child scope.

  $excel = New-Object -ComObject excel.application # create excel object

  # ... work with the $excel object and its object model,
  #     using whatever local variables needed.

  # You must *always* call .Quit(), otherwise the Excel process lingers
  # for the entire OS user session.
  $excel.Quit()

} # All variables created inside { ... } go out of scope 
  # when this block is exited, ensuring release of all COM objects.

For general guidance on how to release (Excel) COM objects, see the bottom section.


Background information:

$excel.Quit() is enough to eventually terminate the Excel process, but when that happens depends on when the garbage collector happens to run the next time.

Your attempt to explicitly release Excel with [System.Runtime.InteropServices.Marshal]::ReleaseComObject($excel) is insufficient, because variables $script:workbook and $script:ws1 still have references to Excel COM objects that will not be released until the variables have gone out of scope and these references are eventually garbage collected.

Therefore, in order to speed up the release, you must release these references explicitly too, before running the garbage collector:

$script:excel = new-object -ComObject excel.application # create excel object
$script:workbook = $excel.Workbooks.Add() # add a workbook
$script:ws1 = $workbook.Worksheets.Item(1) # reference the 1st sheet

# ...

# You must *always* call .Quit(), otherwise the Excel process lingers
# for the entire OS user session.
$script.excel.Quit()

# Relinquish references to *all* Excel objects.
$script:excel = $script:workbook = $script:ws1 = $null
# Alternative:
# Remove-Variable -Scope Script excel, workbook, ws1

# With all references released, running the garbage collector
# should now release the COM objects and terminate Excel
# shortly after.
[GC]::Collect()
# Note that calling [GC]::WaitForPendingFinalizers() afterwards
# to wait for *completion* of the *doesn't work here*,
# because the CLR-managed RCWs (Runtime-Callable Wrappers for COM objects)
# do not guarantee deterministic release of the underlying COM objects.

Preferable alternative:

  • Because manually clearing / removing all relevant variables is error-prone and cumbersome, you can instead automate the process, by creating all variables that reference COM objects locally in a temporary child scope, using & { ... }:
# PREFERABLE ALTERNATIVE to the code above:
& {  # Create a temporary child scope.

  $excel = new-object -ComObject excel.application # create excel object
  $workbook = $excel.Workbooks.Add() # add a workbook
  $ws1 = $workbook.Worksheets.Item(1) # reference the 1st sheet

  # You must *always* call .Quit(), otherwise the Excel process lingers
  # for the entire OS users session.
  $excel.Quit()

} # On exiting this block, $excel, $workbook, and $ws1
  # go out of scope and release the COM objects when the
  # garbage collector runs next.

# Run the garbage collector now.
# The Excel process should terminate shortly after.
[GC]::Collect()

Releasing (Excel) COM Objects:

  • ALWAYS call .Quit() - without it, the Excel process that is created behind the scenes is never terminated, not even when the PowerShell session ends (of course, it is terminated when the OS user session as a whole ends).

  • $excel.Quit() is usually all that is needed (unless global variables variables are used to store references to Excel objects), because with the script / function variables that reference COM objects going out of scope, the underlying COM objects are eventually released automatically too.

    • However, it may take a - varying, unpredictable - while for the Excel process to actually terminate, depending on when the objects that that the gone-out-of-scope variables are garbage-collected.
  • If you want the COM objects to be released as quickly as possible:

    • You must release references to all COM objects you've stored in individual variables:

      • Note that there is NO NEED for [System.Runtime.InteropServices.Marshal]::ReleaseComObject() calls; because there is a simpler and more robust alternative:
      • Either: Clear all variables referencing COM objects explicitly, by (see first code snippet above):
        • either: setting them all to $null.
        • or: passing their names to Remove-Variable
      • Or, preferably: Release the references implicitly (see second code snippet above):
        • Use variables that reference COM objects in a child scope, via a & { ... } block, which means that the references will implicitly be released on leaving the child scope.
    • These approaches are not only simpler and more concise than calling [System.Runtime.InteropServices.Marshal]::ReleaseComObject(), but also prevent later attempts at accessing already-released COM objects.

    • Afterwards, call [GC]::Collect() to force instant garbage collection - but note that your code is blocked while the garbage collector runs (albeit usually only briefly) .

  • If you additionally want to make sure that releasing the COM objects has completed before you continue:

    • Note: There's probably rarely a need for this, because Excel usually releases resources when its .Quit() method is called, such as closing files it has open.

    • You can call [GC]::WaitForPendingFinalizers() after calling [GC]::Collect(), but it is likely not to work: The RCWs (runtime-callable wrappers) that manage access to the COM objects themselves being finalized does not guarantee release of the COM resources at that time; from the docs (emphasis added):

      • "When the reference count on the COM object becomes 0, the COM object is usually freed, although this depends on the COM object's implementation and is beyond the control of the runtime."

      • Indeed, in the case at hand the Excel process does not terminate before the [GC]::WaitForPendingFinalizers() call returns; that only happens within a second or so afterwards.

Esplanade answered 29/3, 2019 at 18:19 Comment(3)
Awesome stuff, but would you know how to quit the application in constrained language mode? $excel.quit() is not allowed. The only method i've found is by killing the process, which could suck if any other excel process was open.Southpaw
@C.Graham: Interesting question; can't think of a solution off the top of my head, but I encourage you to create a new question post focused on just that, which will also provide space for a proper answer.Esplanade
I came up with one, but it involved making an array of PIDs before calling the new comobject, and closing any that are open not in the array after saving the excel. Posted the Q & A here #69049551Southpaw
O
2

Another very hacky way of doing this is to show the excel window, then use the hwnd to identify and shut down the process :)

# Create the Excel object
$Excel = New-Object -Com Excel.Application

### Do your stuff ###

# Show the spreadsheet so that a HWND value exists in the COM Object
$Excel.Visible = $true

# Save spreadsheet
$Workbook.Save()

# Close spreadsheet using generated HWND
Get-Process -Name "*excel*" | Where-Object {$_.MainWindowHandle -eq $Excel.hwnd} | Stop-Process
Oulu answered 5/3, 2022 at 23:9 Comment(1)
I guess this is a cool and useful way to close a window, but does it also remove the old COM objects as described by mklement0, if you have more than one Excel window open?Hage

© 2022 - 2024 — McMap. All rights reserved.