Rewrite windowsupdate package to fix COM memory leaks - #2615
Rewrite windowsupdate package to fix COM memory leaks#2615directionless wants to merge 3 commits into
Conversation
31ac81a to
f56854c
Compare
The Windows Update COM interop code had severe memory leaks caused by missing IUnknown.Release() and VARIANT.Clear() calls throughout the object lifecycle. The intermediate oleconv package made this worse by consuming VARIANT pointers before callers could clean them up, turning every property access into a leak. This commit rewrites the package following the patterns already established in ee/wmi/wmi.go: - Add olehelpers.go with centralized property/method accessors that defer VARIANT.Clear() before extracting Go values, ensuring every COM call site releases native memory correctly. - Release the IUnknown ref from CreateObject after QueryInterface (CreateObject returns refcount=1, QueryInterface bumps to 2, so the IUnknown ref must be released to avoid a leak). - Defer IDispatch.Release() in every toXxx conversion function so intermediate dispatch pointers don't outlive their scope. - Add explicit Release() methods on types that retain an IDispatch for subsequent method calls (IUpdateSession, IUpdate). - Delete pkg/windows/oleconv entirely — it had no other consumers and its abstraction actively prevented correct resource management. - Move package from pkg/windows/windowsupdate to ee/windowsupdate. - Add Windows integration tests exercising the full COM lifecycle (session creation, searcher, history queries). - Add BenchmarkQueryHistory that measures non-Go memory growth per iteration using ee/tables/ci, with a 64 KiB/op threshold that will fail CI if native memory leaks regress. - Add RequireNonGolangMemoryBelowThreshold to ee/tables/ci for reusable native memory leak detection in benchmarks. - Add ee/windowsupdate to the test-bench-tables Makefile target. See ee/windowsupdate/COM_LEAK_ANALYSIS-2026-04.md for the full root cause analysis and design rationale.
0ccbaa4 to
74d2614
Compare
| on process exit. See `ee/tables/windowsupdatetable/windowsupdate.go` and | ||
| `cmd/launcher/query_windowsupdates_windows.go`. |
There was a problem hiding this comment.
Might be clearer to link to the PR, for if/when that workaround goes away.
| on process exit. See `ee/tables/windowsupdatetable/windowsupdate.go` and | |
| `cmd/launcher/query_windowsupdates_windows.go`. | |
| on process exit. See https://github.com/kolide/launcher/pull/2185. |
| func toICategories(categoriesDisp *ole.IDispatch) ([]*ICategory, error) { | ||
| count, err := getPropertyInt32(categoriesDisp, "Count") | ||
| if err != nil { | ||
| return nil, fmt.Errorf("Count: %w", err) |
There was a problem hiding this comment.
Nitpick (applies to all the wrapped errors in this package) for slightly more verbosity + adhering to error string capitalization standards --
| return nil, fmt.Errorf("Count: %w", err) | |
| return nil, fmt.Errorf("getting Count property: %w", err) |
| return nil, fmt.Errorf("Order: %w", err) | ||
| } | ||
|
|
||
| // Parent is commented out to avoid infinite recursion (Parent -> Category -> Parent ...) |
There was a problem hiding this comment.
| // Parent is commented out to avoid infinite recursion (Parent -> Category -> Parent ...) | |
| // Parent is omitted to avoid infinite recursion (Parent -> Category -> Parent ...) |
| return nil, fmt.Errorf("Type: %w", err) | ||
| } | ||
|
|
||
| // Updates is commented out to avoid pulling the full update tree per category. |
There was a problem hiding this comment.
| // Updates is commented out to avoid pulling the full update tree per category. | |
| // Updates is omitted to avoid pulling the full update tree per category. |
| if disp == nil { | ||
| return nil, nil | ||
| } |
There was a problem hiding this comment.
Do we need this nil check elsewhere? We don't have it for e.g. toISearchResult, toICategories, toICategory
| //nolint:unused | ||
| func toIUpdateCollection(updates []*IUpdate) (*ole.IDispatch, error) { |
| if identityDisp, err := getPropertyDispatch(updateDisp, "Identity"); err != nil { | ||
| return nil, err | ||
| } else if identityDisp != nil { | ||
| // toIUpdateIdentity calls Release() on identityDisp internally |
There was a problem hiding this comment.
As I'm reading the PR, I am thinking it might be nice to make it clearer via function names when the function is handling calling Release versus when it's the caller's responsibility. Maybe a pattern like toIUpdateIdentityWithRelease?
There was a problem hiding this comment.
Post-review, this is the biggest comment I have -- it's hard to track exactly when a disp should be released or when it's already been handled, and since we can panic on double-release it feels important to document more strongly
| ServiceID string | ||
| } | ||
|
|
||
| func toIUpdateSearcher(updateSearcherDisp *ole.IDispatch) (*IUpdateSearcher, error) { |
There was a problem hiding this comment.
When will this disp get released?
| searchResultDisp, err := callMethodDispatch(iUpdateSearcher.disp, "Search", criteria) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("calling Search: %w", err) | ||
| return nil, fmt.Errorf("Search: %w", err) |
There was a problem hiding this comment.
As with the others -- I would revert all the changes to the error strings in this file to conform to error string casing standards:
| return nil, fmt.Errorf("Search: %w", err) | |
| return nil, fmt.Errorf("calling Search: %w", err) |
| // Release()/Clear() calls would show up. The test fails if per-op | ||
| // native growth exceeds the threshold. |
There was a problem hiding this comment.
Since we aren't failing the benchmark anymore --
| // Release()/Clear() calls would show up. The test fails if per-op | |
| // native growth exceeds the threshold. | |
| // Release()/Clear() calls would show up. |
The Windows Update COM interop code had severe memory leaks caused by missing IUnknown.Release() and VARIANT.Clear() calls throughout the object lifecycle. The intermediate oleconv package made this worse by consuming VARIANT pointers before callers could clean them up, turning every property access into a leak.
This commit rewrites the package following the patterns already established in ee/wmi/wmi.go:
Add olehelpers.go with centralized property/method accessors that defer VARIANT.Clear() before extracting Go values, ensuring every COM call site releases native memory correctly.
Release the IUnknown ref from CreateObject after QueryInterface (CreateObject returns refcount=1, QueryInterface bumps to 2, so the IUnknown ref must be released to avoid a leak).
Defer IDispatch.Release() in every toXxx conversion function so intermediate dispatch pointers don't outlive their scope.
Add explicit Release() methods on types that retain an IDispatch for subsequent method calls (IUpdateSession, IUpdate).
Delete pkg/windows/oleconv entirely — it had no other consumers and its abstraction actively prevented correct resource management.
Move package from pkg/windows/windowsupdate to ee/windowsupdate.
Add Windows integration tests exercising the full COM lifecycle (session creation, searcher, history queries).
Add BenchmarkQueryHistory that measures non-Go memory growth per iteration using ee/tables/ci, with a 64 KiB/op threshold that will fail CI if native memory leaks regress.
Add RequireNonGolangMemoryBelowThreshold to ee/tables/ci for reusable native memory leak detection in benchmarks.
Add ee/windowsupdate to the test-bench-tables Makefile target.
See ee/windowsupdate/COM_LEAK_ANALYSIS-2026-04.md for the full root cause analysis and design rationale.