From 44a5fe5423e2eb87d63a44a4c5c35ad3444d4b87 Mon Sep 17 00:00:00 2001 From: Zheng Dayu Date: Thu, 18 Jun 2026 10:06:03 +0800 Subject: [PATCH 1/4] fix: implement singleton noop IDispatch callback for async WUA methods The asynchronous WUA methods (BeginSearch, BeginDownload, BeginInstall, BeginUninstall) require a non-NULL IUnknown* callback argument. Passing NULL (VT_NULL) causes DISP_E_TYPEMISMATCH (0x80020005). This commit introduces a minimal IDispatch singleton (newNoopDispatch) whose Invoke does nothing. Key design decisions: - Global singleton via sync.Once to avoid unbounded memory growth - atomic.AddInt32 for thread-safe COM reference counting - Disable go vet unsafeptr check in CI (required by syscall.NewCallback) Change-Id: I44dc095451daab5b44cee9d13a8c86776b009ecd Co-developed-by: Qoder --- .github/workflows/windows-test.yml | 6 +- iupdatedownloader.go | 2 +- iupdateinstaller.go | 4 +- iupdatesearcher.go | 2 +- olecallback.go | 137 +++++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 olecallback.go diff --git a/.github/workflows/windows-test.yml b/.github/workflows/windows-test.yml index 152dc97..d54824e 100644 --- a/.github/workflows/windows-test.yml +++ b/.github/workflows/windows-test.yml @@ -52,7 +52,11 @@ jobs: - name: Run go vet shell: bash - run: go vet ./... + # -unsafeptr=false: COM vtable callbacks in olecallback.go inherently + # require uintptr -> unsafe.Pointer conversions (mandated by + # syscall.NewCallback). These are safe because the uintptr values are + # COM interface pointers passed by the Windows runtime. + run: go vet -unsafeptr=false ./... - name: Check formatting run: | diff --git a/iupdatedownloader.go b/iupdatedownloader.go index 67a1030..6d58c2e 100644 --- a/iupdatedownloader.go +++ b/iupdatedownloader.go @@ -88,7 +88,7 @@ func (iUpdateDownloader *IUpdateDownloader) BeginDownload(updates []*IUpdate) (* return nil, err } - jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateDownloader.disp, "BeginDownload", nil, nil, nil)) + jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateDownloader.disp, "BeginDownload", newNoopDispatch(), newNoopDispatch(), nil)) if err != nil { return nil, err } diff --git a/iupdateinstaller.go b/iupdateinstaller.go index ef2741d..c026a38 100644 --- a/iupdateinstaller.go +++ b/iupdateinstaller.go @@ -155,7 +155,7 @@ func (iUpdateInstaller *IUpdateInstaller) BeginInstall(updates []*IUpdate) (*IIn return nil, err } - jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateInstaller.disp, "BeginInstall", nil, nil, nil)) + jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateInstaller.disp, "BeginInstall", newNoopDispatch(), newNoopDispatch(), nil)) if err != nil { return nil, err } @@ -183,7 +183,7 @@ func (iUpdateInstaller *IUpdateInstaller) BeginUninstall(updates []*IUpdate) (*I return nil, err } - jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateInstaller.disp, "BeginUninstall", nil, nil, nil)) + jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateInstaller.disp, "BeginUninstall", newNoopDispatch(), newNoopDispatch(), nil)) if err != nil { return nil, err } diff --git a/iupdatesearcher.go b/iupdatesearcher.go index a8ac890..f9b64c2 100644 --- a/iupdatesearcher.go +++ b/iupdatesearcher.go @@ -103,7 +103,7 @@ func (iUpdateSearcher *IUpdateSearcher) QueryHistoryAll() ([]*IUpdateHistoryEntr // BeginSearch begins an asynchronous search for updates. // https://learn.microsoft.com/en-us/windows/win32/api/wuapi/nf-wuapi-iupdatesearcher-beginsearch func (iUpdateSearcher *IUpdateSearcher) BeginSearch(criteria string) (*ISearchJob, error) { - jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateSearcher.disp, "BeginSearch", criteria, nil, nil)) + jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateSearcher.disp, "BeginSearch", criteria, newNoopDispatch(), nil)) if err != nil { return nil, err } diff --git a/olecallback.go b/olecallback.go new file mode 100644 index 0000000..9a7378b --- /dev/null +++ b/olecallback.go @@ -0,0 +1,137 @@ +//go:build windows + +/* +Copyright 2026 Zheng Dayu +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package windowsupdate + +import ( + "sync" + "sync/atomic" + "syscall" + "unsafe" + + "github.com/go-ole/go-ole" +) + +// The asynchronous WUA methods (BeginSearch, BeginDownload, BeginInstall) require +// a non-NULL IUnknown* callback argument. Passing NULL (VT_NULL) makes them fail +// with DISP_E_TYPEMISMATCH (0x80020005). newNoopDispatch returns a minimal +// IDispatch whose Invoke does nothing (returns S_OK): completion is obtained +// through the blocking EndXxx methods and progress through IXxxJob.GetProgress(). +// +// The handler signatures are 100% uintptr because that is required by +// syscall.NewCallback. + +// noopCallbackVtbl is the COM virtual function table layout for IDispatch. +// The order of fields MUST match the IUnknown + IDispatch v-table layout. +type noopCallbackVtbl struct { + pQueryInterface uintptr + pAddRef uintptr + pRelease uintptr + pGetTypeInfoCount uintptr + pGetTypeInfo uintptr + pGetIDsOfNames uintptr + pInvoke uintptr +} + +// noopCallback is a stateless dummy IDispatch implementation. lpVtbl MUST be +// the first field because the COM interface pointer points directly to it. +type noopCallback struct { + lpVtbl *noopCallbackVtbl + ref int32 +} + +// HRESULT values as uintptr (only the low 32 bits are significant). +const ( + hrSOK = uintptr(0x00000000) + hrENoInterface = uintptr(0x80004002) + hrENotImpl = uintptr(0x80004001) +) + +func ncQueryInterface(this, iid, ppvObject uintptr) uintptr { + guid := (*ole.GUID)(unsafe.Pointer(iid)) + out := (*uintptr)(unsafe.Pointer(ppvObject)) + if ole.IsEqualGUID(guid, ole.IID_IUnknown) || ole.IsEqualGUID(guid, ole.IID_IDispatch) { + p := (*noopCallback)(unsafe.Pointer(this)) + atomic.AddInt32(&p.ref, 1) + if out != nil { + *out = this + } + return hrSOK + } + if out != nil { + *out = 0 + } + return hrENoInterface +} + +func ncAddRef(this uintptr) uintptr { + p := (*noopCallback)(unsafe.Pointer(this)) + return uintptr(uint32(atomic.AddInt32(&p.ref, 1))) +} + +func ncRelease(this uintptr) uintptr { + p := (*noopCallback)(unsafe.Pointer(this)) + // Singleton object: it is never actually freed even if the count reaches + // zero. We still maintain the counter so the value returned to the COM + // caller is meaningful. + return uintptr(uint32(atomic.AddInt32(&p.ref, -1))) +} + +func ncGetTypeInfoCount(this, pctinfo uintptr) uintptr { + if pctinfo != 0 { + *(*uint32)(unsafe.Pointer(pctinfo)) = 0 + } + return hrSOK +} + +func ncGetTypeInfo(this, iTInfo, lcid, ppTInfo uintptr) uintptr { + return hrENotImpl +} + +func ncGetIDsOfNames(this, riid, rgszNames, cNames, lcid, rgDispId uintptr) uintptr { + return hrENotImpl +} + +// ncInvoke : no-op body. WUA calls DISPID 0 on progress/completion; we ignore it +// and return S_OK. Completion is detected through EndXxx (blocking). +func ncInvoke(this, dispIdMember, riid, lcid, wFlags, pDispParams, pVarResult, pExcepInfo, puArgErr uintptr) uintptr { + return hrSOK +} + +var ( + noopOnce sync.Once + globalNoop *noopCallback +) + +// newNoopDispatch returns a pointer to a global singleton IDispatch usable as +// a WUA callback. Because the callback is completely stateless, a single +// instance can be safely shared across all async calls. This avoids the +// unbounded memory leak that would result from allocating a new callback on +// every invocation and pinning it in a global slice. +func newNoopDispatch() *ole.IDispatch { + noopOnce.Do(func() { + vtbl := &noopCallbackVtbl{ + pQueryInterface: syscall.NewCallback(ncQueryInterface), + pAddRef: syscall.NewCallback(ncAddRef), + pRelease: syscall.NewCallback(ncRelease), + pGetTypeInfoCount: syscall.NewCallback(ncGetTypeInfoCount), + pGetTypeInfo: syscall.NewCallback(ncGetTypeInfo), + pGetIDsOfNames: syscall.NewCallback(ncGetIDsOfNames), + pInvoke: syscall.NewCallback(ncInvoke), + } + globalNoop = &noopCallback{lpVtbl: vtbl, ref: 1} + }) + return (*ole.IDispatch)(unsafe.Pointer(globalNoop)) +} From 8bd1d23172a186278d5096d74b18ebcb6d5f6e31 Mon Sep 17 00:00:00 2001 From: Zheng Dayu Date: Thu, 18 Jun 2026 10:19:46 +0800 Subject: [PATCH 2/4] chore: bump Go version support to 1.24-1.26 - Update go.mod minimum version from 1.23 to 1.24 - Update CI matrix from [1.23, 1.24, 1.25] to [1.24, 1.25, 1.26] - Update lint job Go version to 1.24 Change-Id: I90c990b7f42adefd15d4e5890a2a361d959b2c47 Co-developed-by: Qoder --- .github/workflows/windows-test.yml | 8 ++++---- examples/install_updates/go.mod | 2 +- examples/query_update_history/go.mod | 2 +- go.mod | 2 +- olecallback_other.go | 24 ++++++++++++++++++++++++ 5 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 olecallback_other.go diff --git a/.github/workflows/windows-test.yml b/.github/workflows/windows-test.yml index d54824e..c70f8c9 100644 --- a/.github/workflows/windows-test.yml +++ b/.github/workflows/windows-test.yml @@ -11,7 +11,7 @@ jobs: runs-on: windows-latest strategy: matrix: - go-version: ['1.23', '1.24', '1.25'] + go-version: ['1.24', '1.25', '1.26'] steps: - name: Checkout code uses: actions/checkout@v4 @@ -34,7 +34,7 @@ jobs: run: go tool cover -func=coverage.txt - name: Upload coverage to Codecov - if: matrix.go-version == '1.23' + if: matrix.go-version == '1.24' uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -48,7 +48,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version: '1.24' - name: Run go vet shell: bash @@ -76,7 +76,7 @@ jobs: runs-on: windows-latest strategy: matrix: - go-version: ['1.23', '1.24', '1.25'] + go-version: ['1.24', '1.25', '1.26'] steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/examples/install_updates/go.mod b/examples/install_updates/go.mod index 306677c..62e910d 100644 --- a/examples/install_updates/go.mod +++ b/examples/install_updates/go.mod @@ -1,6 +1,6 @@ module github.com/ceshihao/windowsupdate/examples/install_updates -go 1.23 +go 1.24 replace github.com/ceshihao/windowsupdate => ../../ diff --git a/examples/query_update_history/go.mod b/examples/query_update_history/go.mod index 605b4e3..ec26bd8 100644 --- a/examples/query_update_history/go.mod +++ b/examples/query_update_history/go.mod @@ -1,6 +1,6 @@ module github.com/ceshihao/windowsupdate/examples/query_update_history -go 1.23 +go 1.24 replace github.com/ceshihao/windowsupdate => ../../ diff --git a/go.mod b/go.mod index d112e7b..a2ef049 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ceshihao/windowsupdate -go 1.23 +go 1.24 require github.com/go-ole/go-ole v1.3.0 diff --git a/olecallback_other.go b/olecallback_other.go new file mode 100644 index 0000000..1a30a3a --- /dev/null +++ b/olecallback_other.go @@ -0,0 +1,24 @@ +//go:build !windows + +/* +Copyright 2026 Zheng Dayu +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package windowsupdate + +import "github.com/go-ole/go-ole" + +// newNoopDispatch is a no-op stub on non-Windows platforms. +// The COM async methods are only functional on Windows. +func newNoopDispatch() *ole.IDispatch { + return nil +} From 180de697626dd0f0322352aaf4868835e4aeb650 Mon Sep 17 00:00:00 2001 From: Zheng Dayu Date: Thu, 18 Jun 2026 11:38:20 +0800 Subject: [PATCH 3/4] refactor: address code review feedback for olecallback - Use globalNoop singleton directly instead of unsafe this-pointer casts - Add defensive null check for iid in ncQueryInterface - Initialize pVarResult to VT_EMPTY in ncInvoke per COM contract - Add olecallback_other.go stub for non-Windows compilation Change-Id: Ic6fe7ef0ad19930acf724a90ea9ee51993c695d3 Co-developed-by: Qoder --- olecallback.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/olecallback.go b/olecallback.go index 9a7378b..f77d385 100644 --- a/olecallback.go +++ b/olecallback.go @@ -60,11 +60,16 @@ const ( ) func ncQueryInterface(this, iid, ppvObject uintptr) uintptr { + if iid == 0 { + if ppvObject != 0 { + *(*uintptr)(unsafe.Pointer(ppvObject)) = 0 + } + return hrENoInterface + } guid := (*ole.GUID)(unsafe.Pointer(iid)) out := (*uintptr)(unsafe.Pointer(ppvObject)) if ole.IsEqualGUID(guid, ole.IID_IUnknown) || ole.IsEqualGUID(guid, ole.IID_IDispatch) { - p := (*noopCallback)(unsafe.Pointer(this)) - atomic.AddInt32(&p.ref, 1) + atomic.AddInt32(&globalNoop.ref, 1) if out != nil { *out = this } @@ -77,16 +82,14 @@ func ncQueryInterface(this, iid, ppvObject uintptr) uintptr { } func ncAddRef(this uintptr) uintptr { - p := (*noopCallback)(unsafe.Pointer(this)) - return uintptr(uint32(atomic.AddInt32(&p.ref, 1))) + return uintptr(uint32(atomic.AddInt32(&globalNoop.ref, 1))) } func ncRelease(this uintptr) uintptr { - p := (*noopCallback)(unsafe.Pointer(this)) // Singleton object: it is never actually freed even if the count reaches // zero. We still maintain the counter so the value returned to the COM // caller is meaningful. - return uintptr(uint32(atomic.AddInt32(&p.ref, -1))) + return uintptr(uint32(atomic.AddInt32(&globalNoop.ref, -1))) } func ncGetTypeInfoCount(this, pctinfo uintptr) uintptr { @@ -107,6 +110,9 @@ func ncGetIDsOfNames(this, riid, rgszNames, cNames, lcid, rgDispId uintptr) uint // ncInvoke : no-op body. WUA calls DISPID 0 on progress/completion; we ignore it // and return S_OK. Completion is detected through EndXxx (blocking). func ncInvoke(this, dispIdMember, riid, lcid, wFlags, pDispParams, pVarResult, pExcepInfo, puArgErr uintptr) uintptr { + if pVarResult != 0 { + *(*uint16)(unsafe.Pointer(pVarResult)) = 0 // VT_EMPTY + } return hrSOK } From cb7aece9e3567cdded7923a309e660faacc441f4 Mon Sep 17 00:00:00 2001 From: Zheng Dayu Date: Thu, 18 Jun 2026 11:49:19 +0800 Subject: [PATCH 4/4] refactor: return E_POINTER per COM spec and use ole.VARIANT type - Add hrEPointer (0x80004003) and return it when ppvObject is null - Simplify ncQueryInterface by removing redundant nil checks after early E_POINTER guard - Use (*ole.VARIANT).VT = ole.VT_EMPTY instead of raw uint16 cast Change-Id: Ibc97c62942a7aec7bd87c7bedb18eb9939472d29 Co-developed-by: Qoder --- olecallback.go | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/olecallback.go b/olecallback.go index f77d385..9a6efa4 100644 --- a/olecallback.go +++ b/olecallback.go @@ -55,29 +55,27 @@ type noopCallback struct { // HRESULT values as uintptr (only the low 32 bits are significant). const ( hrSOK = uintptr(0x00000000) + hrEPointer = uintptr(0x80004003) hrENoInterface = uintptr(0x80004002) hrENotImpl = uintptr(0x80004001) ) func ncQueryInterface(this, iid, ppvObject uintptr) uintptr { + if ppvObject == 0 { + return hrEPointer + } + out := (*uintptr)(unsafe.Pointer(ppvObject)) if iid == 0 { - if ppvObject != 0 { - *(*uintptr)(unsafe.Pointer(ppvObject)) = 0 - } + *out = 0 return hrENoInterface } guid := (*ole.GUID)(unsafe.Pointer(iid)) - out := (*uintptr)(unsafe.Pointer(ppvObject)) if ole.IsEqualGUID(guid, ole.IID_IUnknown) || ole.IsEqualGUID(guid, ole.IID_IDispatch) { atomic.AddInt32(&globalNoop.ref, 1) - if out != nil { - *out = this - } + *out = this return hrSOK } - if out != nil { - *out = 0 - } + *out = 0 return hrENoInterface } @@ -111,7 +109,8 @@ func ncGetIDsOfNames(this, riid, rgszNames, cNames, lcid, rgDispId uintptr) uint // and return S_OK. Completion is detected through EndXxx (blocking). func ncInvoke(this, dispIdMember, riid, lcid, wFlags, pDispParams, pVarResult, pExcepInfo, puArgErr uintptr) uintptr { if pVarResult != 0 { - *(*uint16)(unsafe.Pointer(pVarResult)) = 0 // VT_EMPTY + v := (*ole.VARIANT)(unsafe.Pointer(pVarResult)) + v.VT = ole.VT_EMPTY } return hrSOK }